Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b92fae76c | ||
|
|
4c76c77005 | ||
|
|
93cff6099e | ||
|
|
7bcaec6284 | ||
|
|
2f4fd7c040 | ||
|
|
1516e104df | ||
|
|
e9588aeba8 | ||
|
|
83df90b531 | ||
|
|
87ffe3b84b | ||
|
|
9a4bf51ad4 | ||
|
|
d84663f901 | ||
|
|
be621c49a2 | ||
|
|
edf7c3052b | ||
|
|
f66968768f | ||
|
|
2166f94247 | ||
|
|
bae9675485 | ||
|
|
13fd3a4cbb | ||
|
|
809b6a11d7 | ||
|
|
f1e0b62c73 | ||
|
|
72ecf500c8 | ||
|
|
df0bf09a0e | ||
|
|
4b413788ac |
+141
@@ -7,6 +7,147 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
- Waitlist for sold-out tickets. A new event setting (event wizard → Visibility) turns it on, with how long offered places are held (24 hours by default) and whether someone can be offered fewer places than they asked for.
|
||||
- Sold-out tickets show "Join waitlist" on the event page, the register page and the events list. Guests can join on events that allow guest registration.
|
||||
- When places free up, the next person is emailed/WhatsApped a claim link, and the places are held for them so nobody else can buy them. Places free up through a cancellation (by the person or staff), a smaller booking, or a raised stock limit.
|
||||
- The claim link opens the register page with the places pre-selected, and works without logging in.
|
||||
- Unclaimed offers expire (checked every 10 minutes) and move on to the next person.
|
||||
- People can leave a waitlist from My Events, which now lists their waitlists and any places held for them.
|
||||
- Supervisor → Waitlist page: the queue per event, with offer now (even over the stock limit), move up/down and remove.
|
||||
- The daily summary email shows each event's waitlist count.
|
||||
- New `/api/waitlist` endpoints. `POST /api/registrations` accepts `waitlistToken`.
|
||||
- Requires the `add_waitlist` database migration.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Manual registrations (Manual registration, At the door, the kiosk) and adding places to an existing booking never checked stock, so either could take an option over its stock limit. Both are now checked like a normal registration.
|
||||
- Stock is now counted in one place (`utils/stock.js`) for registration checks, `availableCount` and `isSoldOut`, including places held for waitlist offers.
|
||||
|
||||
## [1.15.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Parental consent for youth events. A new "Requires parental consent" event setting (event wizard → Visibility) has its own consent text and optional medical info, emergency contact and photo consent questions. It turns on named attendees automatically.
|
||||
- Registering then needs a name and date of birth for every main-ticket place. Anyone under 18 on the event's start date needs a parent or guardian's name, email and phone.
|
||||
- This applies on the registration page, manual registration, at the door, the kiosk, and when editing names in My Events.
|
||||
- The parent gets an email and a WhatsApp (always both) with a link to a new consent page (`/consent/<token>`). There they read the text, answer the optional questions, type their full name and confirm they're the parent or guardian, then give or decline.
|
||||
- The system records the time, IP, device, signed name, and the exact consent text and version shown.
|
||||
- Medical and emergency details are stored encrypted.
|
||||
- The registrant and the event's notification recipients are told the outcome.
|
||||
- Tickets are still issued, but the scanner blocks check-in until consent is given, showing why. Supervisors can override, and the override is recorded in the audit log. On a youth event, a ticket with no named attendee counts as missing consent.
|
||||
- A daily 09:00 job reminds unanswered requests after 2 days, then every 3 days until the event. It also deletes medical and emergency details a set number of days after the event (Site Settings → Legal, default 30). `CONSENT_JOBS_ENABLED=false` turns it off.
|
||||
- Supervisor → Parental consent page: consent status per attendee, resend one or all pending requests, re-request consent after the consent text changes, and a printable, confidential medical & emergency list. Every view of the list is recorded in the audit log.
|
||||
- New reports: "Parental consent status" and "Medical & emergency list".
|
||||
- My Events shows each attendee's consent status, with a "Resend consent" button.
|
||||
- Anonymising or closing an account also clears that person's attendees' parent, date-of-birth, medical and emergency details.
|
||||
- New endpoints under `/api/consent`. `GET /api/tickets/scan-preview/:qrCode` now returns `consentStatus`, and `POST /api/tickets/scan/:qrCode` accepts `overrideConsent`.
|
||||
- Requires the `add_parental_consent` database migration.
|
||||
|
||||
## [1.14.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Group tickets (named attendees). A new "Named attendees" event setting (event wizard → Visibility) lets someone registering several people give each main-ticket place a name, with an optional email and phone. It's offered on the registration page, Manual registration, At the door and the self-service kiosk.
|
||||
- Each named person gets their own single-person ticket, with their name on the ticket PDF, the scanner's confirm screen and the staff ticket list. Places left unnamed share one quantity ticket as before.
|
||||
- The payer chooses whether every ticket comes to them or each person gets their own by email/WhatsApp; anyone without contact details falls back to the payer.
|
||||
- The payer stays the account owner for billing, invoices and payments.
|
||||
- An attendee whose email or phone matches an existing account is linked to it (never when the email and phone belong to two different accounts). Their ticket then appears in their own My Events, marked "Booked by …", and they can open and submit their own attendee form.
|
||||
- My Events → registration popup has a new "People" section for adding or changing names and contact details, switching ticket delivery, and sending an attendee a link to fill in their own form (`/forms?registrationId=…&attendeeId=…`). An attendee whose ticket has been scanned can't be removed.
|
||||
- Attendee forms now show one block per named person, labelled with their name, and each response is saved against that attendee.
|
||||
- New endpoints `PUT /api/registrations/:id/attendees` and `POST /api/registrations/:id/attendees/:attendeeId/send-form-link`. `POST /api/registrations` and `/manual` accept `attendees` and `ticketDelivery`.
|
||||
- Requires the `add_named_attendees` database migration.
|
||||
|
||||
### Changed
|
||||
|
||||
- Reducing the quantity of a main ticket removes the newest names beyond the new quantity.
|
||||
- Check-in confirmations for a named ticket go to that person when the registration sends each person their own tickets.
|
||||
- Unauthenticated guest registration now matches an existing account by phone as well as email, and is refused (409) when the email and phone belong to two different accounts, instead of silently reusing whichever account the email matched.
|
||||
- Free registrations now issue tickets through the same generator as paid ones, instead of a separate copy of the logic.
|
||||
|
||||
### Security
|
||||
|
||||
- Registration details (`GET /api/registrations/:id`) hide attendees' email addresses and phone numbers from anyone other than the payer and staff, such as an attendee opening their form link.
|
||||
|
||||
## [1.13.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
+75
-3
@@ -126,6 +126,28 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
creating an account if needed. Includes a "Record Payment" tab.
|
||||
- **Duplicate-account protection** — if an email and phone belong to two
|
||||
different accounts, the registration is blocked instead of merging them.
|
||||
- **Group tickets** — on events with "Named attendees" turned on, someone booking
|
||||
several places can name each person (with optional email/phone). Each named person
|
||||
gets their own ticket and their own attendee form; unnamed places share one ticket.
|
||||
Tickets go all to the payer, or each to the person (falling back to the payer).
|
||||
Attendees matching an existing account see their ticket in My Events. Names can be
|
||||
added or changed later from My Events, and attendees can be sent a link to fill in
|
||||
their own form. Available on the registration page, manual registration, At the
|
||||
door and the kiosk.
|
||||
- **Waitlist** — on events with a waitlist, sold-out tickets show "Join waitlist"
|
||||
(on the event page, the register page and the event list). When places free up — a
|
||||
cancellation, a smaller booking or a higher stock limit — the next person is
|
||||
emailed/WhatsApped a claim link and the places are held for them (24 hours by
|
||||
default) so nobody else can buy them. Unclaimed offers expire and move on; people
|
||||
who asked for more than came free can be offered what's free or skipped. Claiming
|
||||
works without logging in. People can leave from My Events.
|
||||
- **Parental consent (youth events)** — on events that require it, every attendee
|
||||
gives a date of birth; for anyone under 18, a parent or guardian gets an email and
|
||||
a WhatsApp with a consent link. They read the event's consent text, optionally give
|
||||
medical info, an emergency contact and photo consent, type their name to sign, and
|
||||
give or decline. Tickets are issued but can't be checked in until consent is given
|
||||
(supervisors can override, audited). Reminders go after 2 days, then every 3 days.
|
||||
Medical details are encrypted and deleted a set number of days after the event.
|
||||
- **Coupon codes** — percentage, fixed-amount or free (comp) codes, entered under
|
||||
"Have a code?" on the registration page, in My Events (for unpaid registrations),
|
||||
manual registration, At the door and the kiosk. Codes can be limited to events,
|
||||
@@ -149,6 +171,16 @@ 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.
|
||||
- **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
|
||||
@@ -170,12 +202,21 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
fully paid (or free), and sent as a PDF by email and/or WhatsApp.
|
||||
- **Quantity tickets** — one ticket can cover several people and be redeemed
|
||||
partially (e.g. check in 2 of 3).
|
||||
- **Named tickets** — group bookings can give each person their own ticket, shown
|
||||
with their name on the PDF, the scanner and the staff ticket list.
|
||||
- **QR scanner** — works in any mobile browser. A preview checks the ticket is
|
||||
for the selected event/section, then a confirmation screen shows holder, type
|
||||
and remaining quantity.
|
||||
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were
|
||||
checked in and how many remain.
|
||||
- **Scan stats** — recent scans and per-event scan counts.
|
||||
- **Offline scanning** — while online, a staff member locks an event section (or the
|
||||
whole event) to their device and downloads its tickets. They can then keep scanning
|
||||
with no connection; scans are queued on the device and sync automatically when it's
|
||||
back online (never redeeming more than is left). Only one device can hold a section,
|
||||
and while it's locked nobody else can scan it, online or offline. Problems found
|
||||
while syncing go into a report for a supervisor. Supervisors can force-release a
|
||||
lost device. The scanner can be added to the home screen (PWA) and opens offline.
|
||||
- **Printing** — A4 sheets (8 tickets per page) and A6 single tickets.
|
||||
- **Resend** — send tickets to a different email/phone at the door.
|
||||
|
||||
@@ -235,6 +276,15 @@ 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** — hand a ticket to someone else before the event (name plus
|
||||
email/phone). They get a new ticket and QR code, and an inactive account with an
|
||||
activation link if they're new. The old code stops working, and scanning it says it
|
||||
was transferred, both online and in offline-mode sync reports. One place can be split
|
||||
off a ticket covering several people. Billing stays with the payer. It's not allowed
|
||||
once a ticket has been scanned, and it can be turned off per event (supervisors can
|
||||
always transfer, from At the door → Tickets). On youth events the new holder needs
|
||||
their own parental consent, and the previous holder's form answers are removed.
|
||||
Every transfer is in the audit log.
|
||||
- **Payment history** (`/dashboard/user/payments`) — paginated, with filters by
|
||||
date, method and payment/refund.
|
||||
- **Donate** page.
|
||||
@@ -275,6 +325,24 @@ 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
|
||||
attendee on youth events, resend (one or all pending), re-request after the
|
||||
consent text changes, and a printable medical & emergency list (audited).
|
||||
- **Email attendees / WhatsApp attendees** — bulk and scheduled messaging.
|
||||
- **Forms** — view, filter, edit and print form responses.
|
||||
- **Reports** — see [Reports](#reports).
|
||||
@@ -328,9 +396,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) |
|
||||
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list |
|
||||
| Ticketing | Ticket usage summary |
|
||||
| Donations | Donations breakdown (used / unused per event) |
|
||||
| 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,
|
||||
@@ -400,6 +468,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) |
|
||||
| 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 |
|
||||
|
||||
@@ -432,9 +502,11 @@ falling back to a general guide.
|
||||
| `/contact`, `/legal/terms`, `/legal/privacy`, `/lockdown-rules` | Public | Info pages |
|
||||
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
|
||||
| `/setup` | First run | Setup wizard |
|
||||
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
|
||||
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/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 |
|
||||
|
||||
@@ -66,6 +66,7 @@ or Branding tab has been saved even once — the wizard always writes
|
||||
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
|
||||
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
|
||||
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
|
||||
## WhatsApp
|
||||
|
||||
@@ -52,6 +52,11 @@ WAWP_INSTANCE_ID=your_wawp_instance_id
|
||||
DAILY_SUMMARY_ENABLED=true
|
||||
SCHEDULED_EMAILS_ENABLED=true
|
||||
SCHEDULED_EMAILS_INTERVAL_MS=30000
|
||||
# Daily 09:00 parental-consent job: reminders, and deleting medical details after events
|
||||
CONSENT_JOBS_ENABLED=true
|
||||
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
|
||||
WAITLIST_JOB_ENABLED=true
|
||||
# WAITLIST_JOB_INTERVAL_MS=600000
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
@@ -19,6 +19,12 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Event Attachments](#event-attachments)
|
||||
- [Registrations](#registrations)
|
||||
- [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)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -229,6 +235,8 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
|
||||
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
|
||||
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
|
||||
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
|
||||
| PUT | `/api/registrations/:id/attendees` | user+ | Replace the named attendees (`{ attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }`); owner or staff |
|
||||
| POST | `/api/registrations/:id/attendees/:attendeeId/send-form-link` | user+ | Email/WhatsApp an attendee the link to their own form |
|
||||
| POST | `/api/registrations/:id/coupon` | user+ | Apply a code to an existing registration (owner, or staff on someone's behalf) |
|
||||
| DELETE | `/api/registrations/:id/coupon` | staff+ | Remove the code (refused once a ticket on a paid registration has been scanned) |
|
||||
|
||||
@@ -244,6 +252,12 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
|
||||
```
|
||||
Add `"couponCode": "STAFF50"` to apply a discount code. The code is checked before anything is saved (400 with the reason if it can't be used). If the last use is taken by someone else in the moment between checking and saving, the registration is still created at full price and the response includes `couponError`.
|
||||
|
||||
**Group tickets:** on events with `allowNamedAttendees`, `POST /api/registrations` and `/manual` accept `attendees: [{ eventOptionId, variantId?, name, email?, phone? }]` (main-ticket places only, no more than the places bought) and `ticketDelivery: "payer" | "each"`.
|
||||
- Each named attendee gets their own quantity-1 ticket; unnamed places share one quantity ticket.
|
||||
- An attendee whose email/phone matches one existing account is linked to it, so the ticket also appears in that person's `GET /api/tickets/mytickets`.
|
||||
- With `"each"`, tickets are emailed/WhatsApped to attendees directly, falling back to the payer.
|
||||
- Form responses can carry `attendeeId`.
|
||||
|
||||
Registration responses include `discountTotal` (the coupon discount), `subtotalComputed` (items before the discount), `totalDueComputed` (after it) and `couponRedemptions` (the active code).
|
||||
|
||||
For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
@@ -268,6 +282,134 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
|
||||
---
|
||||
|
||||
## 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?, parentName?, parentEmail?, parentPhone? }` (date of birth, plus parent details for a minor, on youth events) |
|
||||
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
|
||||
|
||||
Rules:
|
||||
- Only before the event starts, and only for tickets that have never been scanned.
|
||||
- `Event.allowTicketTransfer` turns it off for non-supervisors.
|
||||
|
||||
What a transfer does:
|
||||
- The new holder becomes the ticket's attendee and gets a new QR code. A shared ticket for several places has one place split off into its own ticket.
|
||||
- An account is matched by email/phone, or created inactive with an activation link.
|
||||
- The previous holder's attendee form answers are deleted, and the medical details on their consent are cleared. A minor on a youth event gets a new consent request.
|
||||
|
||||
Old QR codes:
|
||||
- `scan-preview` and `scan` return `410 { code: "TICKET_TRANSFERRED" }`.
|
||||
- Offline sync reports them as `transferred`.
|
||||
- Each transfer is audited as `ticket_transferred`.
|
||||
|
||||
## Offline scanning
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/scan-locks?eventId=` | staff+ | Active offline locks for an event |
|
||||
| POST | `/api/scan-locks` | staff+ | Lock `{ eventId, sectionId?, deviceId, deviceName? }` to this device (409 if the scope is held elsewhere) |
|
||||
| GET | `/api/scan-locks/:id/snapshot?deviceId=` | staff+ | Tickets valid in the lock's scope (lock holder only) |
|
||||
| POST | `/api/scan-locks/:id/sync` | staff+ | `{ deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }` — idempotent; returns accepted, duplicates, conflicts |
|
||||
| DELETE | `/api/scan-locks/:id` | staff+ | Release (`{ deviceId }`, lock holder only) |
|
||||
| POST | `/api/scan-locks/:id/force-release` | supervisor+ | Release a lost device's lock |
|
||||
| GET | `/api/scan-locks/reports?eventId=&unreviewed=` | supervisor+ | Sync reports |
|
||||
| POST | `/api/scan-locks/reports/:id/review` | supervisor+ | Mark a report reviewed |
|
||||
|
||||
How locks work:
|
||||
- One device per scope. A whole-event lock (`sectionId` null) clashes with every section lock on that event.
|
||||
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and `deviceId`. It enforces the section on the server, and returns `423 { code: "SCAN_LOCKED" }` when another device holds a lock covering the ticket.
|
||||
|
||||
How syncing works:
|
||||
- Synced scans never redeem more than is left on a ticket.
|
||||
- Conflicts are recorded in the sync report: `not_found`, `cancelled`, `wrong_scope`, `already_used`, `partly_used` and `consent`.
|
||||
- Check-in messages are sent after the sync.
|
||||
- Locks, releases, force-releases and syncs are written to the admin audit log.
|
||||
|
||||
## Waitlist
|
||||
|
||||
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity |
|
||||
| GET | `/api/waitlist/mine` | user+ | My active entries |
|
||||
| DELETE | `/api/waitlist/:id` | user+ | Leave (an open offer moves on) |
|
||||
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers |
|
||||
| GET | `/api/waitlist/events/:eventId` | staff+ | The queue for an event |
|
||||
| POST | `/api/waitlist/:id/offer` | supervisor+ | Offer now (`{ quantity? }`), even over the stock limit |
|
||||
| POST | `/api/waitlist/:id/move` | supervisor+ | `{ direction: "up" \| "down" }` |
|
||||
| POST | `/api/waitlist/:id/remove` | supervisor+ | Take someone off |
|
||||
|
||||
How offers work:
|
||||
- Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks.
|
||||
- Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit.
|
||||
- A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers.
|
||||
- Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer.
|
||||
- Manual registration and adding places to an existing booking are now checked against stock too.
|
||||
|
||||
## Parental consent
|
||||
|
||||
Youth events (`requiresParentalConsent`, plus `consentText`, `consentCollectMedical`, `consentCollectEmergency` and `consentCollectPhoto` on the event) need every main place named with a `dateOfBirth`. Attendees under 18 on the start date also need `parentName`, `parentEmail` and `parentPhone`. Changing `consentText` bumps `consentTextVersion`.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/consent/:token` | public | What the parent sees: attendee, event, consent text, fields to fill in |
|
||||
| POST | `/api/consent/:token` | public | `{ decision: "give"\|"decline", signedName, isGuardian: true, medical?, emergency?, photoConsent? }` |
|
||||
| GET | `/api/consent/events/:eventId` | staff+ | Consent status per attendee, with counts |
|
||||
| POST | `/api/consent/requests/:id/resend` | user+ | Re-send one request with a new link (registrant or staff) |
|
||||
| POST | `/api/consent/events/:eventId/resend-pending` | supervisor+ | Re-send every pending request |
|
||||
| POST | `/api/consent/events/:eventId/rerequest` | supervisor+ | New requests for consent given on older text, and minors with none |
|
||||
| GET | `/api/consent/events/:eventId/medical` | supervisor+ | Decrypted medical & emergency list (audited as `medical_list_viewed`) |
|
||||
|
||||
How requests work:
|
||||
- Requests go to the parent by both email and WhatsApp, whatever anyone's notification preference.
|
||||
- Links are random tokens; only an HMAC is stored. They expire when the event ends.
|
||||
- The answer stores the signed name, time, IP, device, and the exact text and version shown.
|
||||
- Medical and emergency details are AES-256-GCM encrypted. A daily job (09:00, `CONSENT_JOBS_ENABLED=false` to disable) sends reminders (day 2, then every 3 days) and deletes them `consent_medical_retention_days` (default 30) after the event.
|
||||
|
||||
Check-in rules:
|
||||
- `GET /api/tickets/scan-preview/:qrCode` returns `consentStatus` (`not_required`, `missing`, `pending`, `given` or `declined`).
|
||||
- `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given.
|
||||
- Supervisors can pass `overrideConsent: true`, which is audited as `consent_override`.
|
||||
|
||||
## Coupons
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.13.0",
|
||||
"version": "1.20.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.13.0",
|
||||
"version": "1.20.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.13.0",
|
||||
"version": "1.20.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TicketDelivery" AS ENUM ('payer', 'each');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "allowNamedAttendees" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "FormResponse" ADD COLUMN "attendeeId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "ticketDelivery" "TicketDelivery" NOT NULL DEFAULT 'payer';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Ticket" ADD COLUMN "attendeeId" TEXT,
|
||||
ADD COLUMN "holderUserId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Attendee" (
|
||||
"id" TEXT NOT NULL,
|
||||
"registrationId" TEXT NOT NULL,
|
||||
"registrationOptionId" TEXT,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"userId" TEXT,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"gender" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Attendee_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Attendee_registrationId_idx" ON "Attendee"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Attendee_registrationOptionId_idx" ON "Attendee"("registrationOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Attendee_userId_idx" ON "Attendee"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Ticket_attendeeId_key" ON "Ticket"("attendeeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_holderUserId_idx" ON "Ticket"("holderUserId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormResponse" ADD CONSTRAINT "FormResponse_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ConsentStatus" AS ENUM ('pending', 'given', 'declined');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'consent_override';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'consent_rerequested';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'medical_list_viewed';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Attendee" ADD COLUMN "parentEmail" TEXT,
|
||||
ADD COLUMN "parentName" TEXT,
|
||||
ADD COLUMN "parentPhone" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "consentCollectEmergency" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentCollectMedical" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentCollectPhoto" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentText" TEXT,
|
||||
ADD COLUMN "consentTextVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "requiresParentalConsent" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConsentRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"attendeeId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"status" "ConsentStatus" NOT NULL DEFAULT 'pending',
|
||||
"textVersion" INTEGER NOT NULL,
|
||||
"consentText" TEXT NOT NULL,
|
||||
"parentName" TEXT,
|
||||
"parentEmail" TEXT,
|
||||
"parentPhone" TEXT,
|
||||
"signedName" TEXT,
|
||||
"ip" TEXT,
|
||||
"device" TEXT,
|
||||
"medical" TEXT,
|
||||
"emergency" TEXT,
|
||||
"photoConsent" BOOLEAN,
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"remindersSent" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"medicalPurgedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ConsentRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConsentRequest_tokenHash_key" ON "ConsentRequest"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConsentRequest_attendeeId_createdAt_idx" ON "ConsentRequest"("attendeeId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConsentRequest_eventId_status_idx" ON "ConsentRequest"("eventId", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WaitlistStatus" AS ENUM ('waiting', 'offered', 'claimed', 'expired', 'removed', 'left');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "waitlistEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "waitlistOfferHours" INTEGER NOT NULL DEFAULT 24,
|
||||
ADD COLUMN "waitlistPartialOffers" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WaitlistEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"status" "WaitlistStatus" NOT NULL DEFAULT 'waiting',
|
||||
"offeredQuantity" INTEGER,
|
||||
"offerTokenHash" TEXT,
|
||||
"offeredAt" TIMESTAMP(3),
|
||||
"offerExpiresAt" TIMESTAMP(3),
|
||||
"claimedAt" TIMESTAMP(3),
|
||||
"registrationId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WaitlistEntry_offerTokenHash_key" ON "WaitlistEntry"("offerTokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_eventId_status_position_idx" ON "WaitlistEntry"("eventId", "status", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_eventOptionId_status_idx" ON "WaitlistEntry"("eventOptionId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_variantId_idx" ON "WaitlistEntry"("variantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_userId_idx" ON "WaitlistEntry"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_registrationId_idx" ON "WaitlistEntry"("registrationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_acquired';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_released';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_force_released';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'offline_sync';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TicketUsage" ADD COLUMN "clientScanId" TEXT,
|
||||
ADD COLUMN "offline" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "scanLockId" TEXT,
|
||||
ADD COLUMN "syncedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ScanLock" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"sectionId" TEXT,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
"deviceName" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"lockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"snapshotAt" TIMESTAMP(3),
|
||||
"lastSyncAt" TIMESTAMP(3),
|
||||
"releasedAt" TIMESTAMP(3),
|
||||
"forceReleasedById" TEXT,
|
||||
|
||||
CONSTRAINT "ScanLock_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OfflineSyncReport" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scanLockId" TEXT NOT NULL,
|
||||
"accepted" INTEGER NOT NULL DEFAULT 0,
|
||||
"duplicates" INTEGER NOT NULL DEFAULT 0,
|
||||
"conflicts" JSONB NOT NULL DEFAULT '[]',
|
||||
"afterForceRelease" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"reviewedById" TEXT,
|
||||
|
||||
CONSTRAINT "OfflineSyncReport_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_eventId_releasedAt_idx" ON "ScanLock"("eventId", "releasedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_userId_idx" ON "ScanLock"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_sectionId_idx" ON "ScanLock"("sectionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_forceReleasedById_idx" ON "ScanLock"("forceReleasedById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OfflineSyncReport_scanLockId_idx" ON "OfflineSyncReport"("scanLockId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OfflineSyncReport_reviewedById_idx" ON "OfflineSyncReport"("reviewedById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TicketUsage_clientScanId_key" ON "TicketUsage"("clientScanId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_forceReleasedById_fkey" FOREIGN KEY ("forceReleasedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'ticket_transferred';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "allowTicketTransfer" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TicketTransfer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"fromHolderName" TEXT NOT NULL,
|
||||
"fromUserId" TEXT,
|
||||
"toName" TEXT NOT NULL,
|
||||
"toUserId" TEXT,
|
||||
"byUserId" TEXT,
|
||||
"oldQrCode" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TicketTransfer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketTransfer_ticketId_idx" ON "TicketTransfer"("ticketId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketTransfer_oldQrCode_idx" ON "TicketTransfer"("oldQrCode");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_byUserId_fkey" FOREIGN KEY ("byUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+350
-196
@@ -102,40 +102,73 @@ model User {
|
||||
couponRedemptions CouponRedemption[] @relation("CouponRedemptionUser")
|
||||
couponsApplied CouponRedemption[] @relation("CouponRedemptionAppliedBy")
|
||||
|
||||
attendeeProfiles Attendee[]
|
||||
heldTickets Ticket[] @relation("TicketHolder")
|
||||
waitlistEntries WaitlistEntry[]
|
||||
scanLocks ScanLock[] @relation("ScanLockOwner")
|
||||
transfersTo TicketTransfer[] @relation("TransferRecipient")
|
||||
transfersBy TicketTransfer[] @relation("TransferActor")
|
||||
ledGroups AllocationGroup[] @relation("AllocationGroupLeader")
|
||||
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
|
||||
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
|
||||
|
||||
twoFactorChallenges TwoFactorChallenge[]
|
||||
trustedDevices TrustedDevice[]
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
description String?
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
registrationDeadline DateTime?
|
||||
goLiveAt DateTime @default(now())
|
||||
price Float
|
||||
picture String?
|
||||
isActive Boolean @default(true)
|
||||
isHidden Boolean @default(false)
|
||||
requiresAuth Boolean @default(true)
|
||||
requiresRegistration Boolean @default(true)
|
||||
contactName String?
|
||||
contactPhone String?
|
||||
contactEmail String?
|
||||
location String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String?
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
redirectUrl String? @unique
|
||||
eventOptions EventOption[]
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
tickets Ticket[]
|
||||
attachments EventAttachment[]
|
||||
form EventForm?
|
||||
Section Section[]
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
description String?
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
registrationDeadline DateTime?
|
||||
goLiveAt DateTime @default(now())
|
||||
price Float
|
||||
picture String?
|
||||
isActive Boolean @default(true)
|
||||
isHidden Boolean @default(false)
|
||||
requiresAuth Boolean @default(true)
|
||||
requiresRegistration Boolean @default(true)
|
||||
// Lets a payer name each person on a group registration (one named ticket each).
|
||||
allowNamedAttendees Boolean @default(false)
|
||||
// Youth event: every attendee gives a date of birth, and a parent/guardian must give
|
||||
// consent for anyone under 18 before they can be checked in.
|
||||
requiresParentalConsent Boolean @default(false)
|
||||
consentText String?
|
||||
consentTextVersion Int @default(1)
|
||||
consentCollectMedical Boolean @default(false)
|
||||
consentCollectEmergency Boolean @default(false)
|
||||
consentCollectPhoto Boolean @default(false)
|
||||
// Waitlist for sold-out options: when places free up, the next person is offered
|
||||
// them for waitlistOfferHours. waitlistPartialOffers: offer what's free even if it's
|
||||
// fewer places than asked for (otherwise skip to the next person who fits).
|
||||
waitlistEnabled Boolean @default(false)
|
||||
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)
|
||||
allocationSets AllocationSet[]
|
||||
waitlistOfferHours Int @default(24)
|
||||
waitlistPartialOffers Boolean @default(true)
|
||||
contactName String?
|
||||
contactPhone String?
|
||||
contactEmail String?
|
||||
location String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String?
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
redirectUrl String? @unique
|
||||
eventOptions EventOption[]
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
tickets Ticket[]
|
||||
attachments EventAttachment[]
|
||||
form EventForm?
|
||||
Section Section[]
|
||||
|
||||
cashupStatus CashupStatus @default(open)
|
||||
cashupDraft Json?
|
||||
@@ -151,8 +184,11 @@ model Event {
|
||||
|
||||
// Users who should receive registration/payment/daily-summary notifications for this
|
||||
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
|
||||
notifyRecipients User[] @relation("EventNotifyRecipients")
|
||||
notifyRecipients User[] @relation("EventNotifyRecipients")
|
||||
coupons CouponEvent[]
|
||||
consentRequests ConsentRequest[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
scanLocks ScanLock[]
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@ -175,6 +211,7 @@ model EventOption {
|
||||
variants OptionVariant[]
|
||||
costs EventCost[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -209,6 +246,7 @@ model OptionVariant {
|
||||
registrationOptions RegistrationOption[]
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
@@ -223,6 +261,8 @@ model Registration {
|
||||
// couponRedemptions. Stored here so every total-due calculation sees it no matter
|
||||
// which relations the caller loaded (see utils/pricing.js computeRegistrationTotalDue).
|
||||
discountTotal Float @default(0)
|
||||
// Where named attendees' tickets go: all to the payer, or each to the attendee.
|
||||
ticketDelivery TicketDelivery @default(payer)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
@@ -232,6 +272,8 @@ model Registration {
|
||||
formResponses FormResponse[]
|
||||
formDrafts FormDraft[]
|
||||
couponRedemptions CouponRedemption[]
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([eventId])
|
||||
@@ -254,6 +296,7 @@ model RegistrationOption {
|
||||
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
|
||||
tickets Ticket[]
|
||||
tranches RegistrationOptionTranche[]
|
||||
attendees Attendee[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([eventOptionId])
|
||||
@@ -291,6 +334,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?
|
||||
@@ -320,13 +368,21 @@ model Ticket {
|
||||
quantity Int @default(1)
|
||||
isUsed Boolean @default(false)
|
||||
emailSent Boolean @default(false)
|
||||
// Set on a named (group) ticket: the person it's for, and their account when it
|
||||
// matched one. userId stays the payer.
|
||||
attendeeId String? @unique
|
||||
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
|
||||
holderUserId String?
|
||||
holder User? @relation("TicketHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Restrict)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
usages TicketUsage[]
|
||||
transfers TicketTransfer[]
|
||||
|
||||
@@index([holderUserId])
|
||||
@@index([eventId])
|
||||
@@index([userId])
|
||||
@@index([registrationOptionId])
|
||||
@@ -334,13 +390,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])
|
||||
@@ -422,6 +485,14 @@ enum AdminAuditAction {
|
||||
coupon_updated
|
||||
coupon_disabled
|
||||
coupon_applied_manual
|
||||
consent_override
|
||||
consent_rerequested
|
||||
medical_list_viewed
|
||||
scan_lock_acquired
|
||||
scan_lock_released
|
||||
scan_lock_force_released
|
||||
offline_sync
|
||||
ticket_transferred
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -499,6 +570,8 @@ model FormResponse {
|
||||
registrationId String
|
||||
createdAt DateTime @default(now())
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||
attendeeId String?
|
||||
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
|
||||
answers FormAnswer[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@ -560,6 +633,7 @@ model Section {
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
allowedOptions SectionOption[]
|
||||
scanLocks ScanLock[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -672,167 +746,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
|
||||
@@ -923,3 +836,244 @@ model CouponRedemption {
|
||||
@@index([userId])
|
||||
@@index([appliedById])
|
||||
}
|
||||
|
||||
// ─── Group tickets ────────────────────────────────────────────────────────────
|
||||
|
||||
enum TicketDelivery {
|
||||
payer // every ticket goes to the person who registered
|
||||
each // each named attendee gets their own (falls back to the payer)
|
||||
}
|
||||
|
||||
// One named person on a group registration, for one main-ticket place. Gets their
|
||||
// own quantity-1 ticket and their own attendee form response.
|
||||
/// A 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 ticket handed to someone else. oldQrCode is the code that stopped working (null
|
||||
/// when one place was split off a shared group ticket, whose code stays valid).
|
||||
model TicketTransfer {
|
||||
id String @id @default(uuid())
|
||||
ticketId String
|
||||
fromHolderName String
|
||||
fromUserId String?
|
||||
toName String
|
||||
toUserId String?
|
||||
byUserId String?
|
||||
oldQrCode String?
|
||||
createdAt DateTime @default(now())
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
toUser User? @relation("TransferRecipient", fields: [toUserId], references: [id], onDelete: SetNull)
|
||||
byUser User? @relation("TransferActor", fields: [byUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([ticketId])
|
||||
@@index([oldQrCode])
|
||||
}
|
||||
|
||||
model Attendee {
|
||||
id String @id @default(uuid())
|
||||
registrationId String
|
||||
registrationOptionId String?
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
name String
|
||||
email String?
|
||||
phone String?
|
||||
userId String? // existing account matched by email/phone
|
||||
dateOfBirth DateTime?
|
||||
gender String?
|
||||
parentName String?
|
||||
parentEmail String?
|
||||
parentPhone String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||
registrationOption RegistrationOption? @relation(fields: [registrationOptionId], references: [id], onDelete: SetNull)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
ticket Ticket?
|
||||
formResponses FormResponse[]
|
||||
consentRequests ConsentRequest[]
|
||||
allocations AllocationAssignment[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([registrationOptionId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ─── Parental consent ─────────────────────────────────────────────────────────
|
||||
|
||||
enum ConsentStatus {
|
||||
pending
|
||||
given
|
||||
declined
|
||||
}
|
||||
|
||||
// A request for a parent/guardian to consent for one under-18 attendee. Only the hash
|
||||
// of the link token is stored. A new request (e.g. after the consent text changes)
|
||||
// supersedes earlier ones for the same attendee; the latest one decides check-in.
|
||||
model ConsentRequest {
|
||||
id String @id @default(uuid())
|
||||
attendeeId String
|
||||
eventId String
|
||||
tokenHash String @unique
|
||||
status ConsentStatus @default(pending)
|
||||
textVersion Int
|
||||
consentText String // the exact text the parent was shown
|
||||
parentName String?
|
||||
parentEmail String?
|
||||
parentPhone String?
|
||||
signedName String?
|
||||
ip String?
|
||||
device String?
|
||||
medical String? // encrypted JSON (utils/encryption.js); purged after the event
|
||||
emergency String? // encrypted JSON; purged after the event
|
||||
photoConsent Boolean?
|
||||
respondedAt DateTime?
|
||||
lastSentAt DateTime @default(now())
|
||||
remindersSent Int @default(0)
|
||||
expiresAt DateTime
|
||||
medicalPurgedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([attendeeId, createdAt])
|
||||
@@index([eventId, status])
|
||||
}
|
||||
|
||||
// ─── Waitlist ─────────────────────────────────────────────────────────────────
|
||||
|
||||
enum WaitlistStatus {
|
||||
waiting
|
||||
offered // places held for them until offerExpiresAt
|
||||
claimed // they registered with the offer
|
||||
expired // the offer ran out
|
||||
removed // taken off by staff
|
||||
left // they left the waitlist
|
||||
}
|
||||
|
||||
model WaitlistEntry {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
userId String
|
||||
quantity Int
|
||||
position Int
|
||||
status WaitlistStatus @default(waiting)
|
||||
offeredQuantity Int?
|
||||
offerTokenHash String? @unique
|
||||
offeredAt DateTime?
|
||||
offerExpiresAt DateTime?
|
||||
claimedAt DateTime?
|
||||
registrationId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([eventId, status, position])
|
||||
@@index([eventOptionId, status])
|
||||
@@index([variantId])
|
||||
@@index([userId])
|
||||
@@index([registrationId])
|
||||
}
|
||||
|
||||
// ─── Offline scanning ─────────────────────────────────────────────────────────
|
||||
|
||||
// Locks a section (or the whole event, sectionId null) to one device for offline
|
||||
// scanning, so two devices can never redeem the same ticket while offline.
|
||||
// One active (releasedAt null) lock per scope — enforced in utils/scanLocks.js.
|
||||
model ScanLock {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
sectionId String?
|
||||
deviceId String
|
||||
deviceName String?
|
||||
userId String
|
||||
lockedAt DateTime @default(now())
|
||||
snapshotAt DateTime?
|
||||
lastSyncAt DateTime?
|
||||
releasedAt DateTime?
|
||||
forceReleasedById String?
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||
user User @relation("ScanLockOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
forceReleasedBy User? @relation("ScanLockForcedBy", fields: [forceReleasedById], references: [id], onDelete: SetNull)
|
||||
usages TicketUsage[]
|
||||
syncReports OfflineSyncReport[]
|
||||
|
||||
@@index([eventId, releasedAt])
|
||||
@@index([userId])
|
||||
@@index([sectionId])
|
||||
@@index([forceReleasedById])
|
||||
}
|
||||
|
||||
// One sync of queued offline scans; `conflicts` lists scans that needed a person to look.
|
||||
model OfflineSyncReport {
|
||||
id String @id @default(uuid())
|
||||
scanLockId String
|
||||
accepted Int @default(0)
|
||||
duplicates Int @default(0)
|
||||
conflicts Json @default("[]")
|
||||
afterForceRelease Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
reviewedAt DateTime?
|
||||
reviewedById String?
|
||||
scanLock ScanLock @relation(fields: [scanLockId], references: [id], onDelete: Cascade)
|
||||
reviewedBy User? @relation("SyncReportReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([scanLockId])
|
||||
@@index([reviewedById])
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { describeUserAgent } = require('../utils/userAgent');
|
||||
const {
|
||||
isMinor, consentStatusFor, validateConsentAnswer, sealJson, openJson, latestRequestsFor,
|
||||
findRequestByToken, resendConsentRequest, createConsentRequest,
|
||||
} = require('../utils/consent');
|
||||
|
||||
const STAFF = ['admin', 'supervisor', 'staff'];
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
// What the parent sees — never the registrant's contact details or other attendees.
|
||||
function publicView(request) {
|
||||
const ev = request.event;
|
||||
const latestForAttendee = request._isLatest !== false;
|
||||
return {
|
||||
status: request.status,
|
||||
superseded: !latestForAttendee,
|
||||
expired: new Date(request.expiresAt) < new Date(),
|
||||
respondedAt: request.respondedAt,
|
||||
attendeeName: request.attendee.name,
|
||||
parentName: request.parentName,
|
||||
consentText: request.consentText,
|
||||
textVersion: request.textVersion,
|
||||
event: { title: ev.title, startDate: ev.startDate, endDate: ev.endDate, location: ev.location },
|
||||
collect: { medical: ev.consentCollectMedical, emergency: ev.consentCollectEmergency, photo: ev.consentCollectPhoto },
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Consent page data for a parent/guardian
|
||||
// @route GET /api/consent/:token
|
||||
// @access Public (the token is the credential)
|
||||
const getConsent = async (req, res) => {
|
||||
try {
|
||||
const request = await findRequestByToken(req.params.token);
|
||||
if (!request) { res.status(404); throw new Error('This consent link is not valid. It may have been replaced by a newer one — check your latest message.'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
res.json(publicView({ ...request, _isLatest: latest?.id === request.id }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Give or decline consent
|
||||
// @route POST /api/consent/:token
|
||||
// @access Public (the token is the credential)
|
||||
const answerConsent = async (req, res) => {
|
||||
try {
|
||||
const request = await findRequestByToken(req.params.token);
|
||||
if (!request) { res.status(404); throw new Error('This consent link is not valid.'); }
|
||||
if (request.status !== 'pending') { res.status(409); throw new Error(`Consent has already been ${request.status}.`); }
|
||||
if (new Date(request.expiresAt) < new Date()) { res.status(410); throw new Error('This consent link has expired because the event has ended.'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
if (latest?.id !== request.id) { res.status(409); throw new Error('A newer consent request replaced this one — please use the link in your latest message.'); }
|
||||
|
||||
let answer;
|
||||
try { answer = validateConsentAnswer(req.body || {}, request.event); } catch (e) { res.status(400); throw e; }
|
||||
|
||||
// Only flips pending → answered once, even if the form is submitted twice at once.
|
||||
const updated = await prisma.consentRequest.updateMany({
|
||||
where: { id: request.id, status: 'pending' },
|
||||
data: {
|
||||
status: answer.decision === 'give' ? 'given' : 'declined',
|
||||
signedName: answer.signedName,
|
||||
ip: getClientIp(req),
|
||||
device: describeUserAgent(req.headers['user-agent']),
|
||||
medical: sealJson(answer.medical),
|
||||
emergency: sealJson(answer.emergency),
|
||||
photoConsent: answer.photoConsent,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) { res.status(409); throw new Error('Consent has already been answered.'); }
|
||||
|
||||
notifyConsentAnswered(request.id).catch(e => console.error('[consent] notify failed:', e?.message || e));
|
||||
res.json({ message: answer.decision === 'give' ? 'Thank you — consent has been given.' : 'Thank you — consent has been declined.', status: answer.decision === 'give' ? 'given' : 'declined' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Tells the registrant and the event's notify recipients how a parent answered.
|
||||
async function notifyConsentAnswered(requestId) {
|
||||
const request = await prisma.consentRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
});
|
||||
if (!request) return;
|
||||
const { sendMail, emailWrapper } = require('../utils/email');
|
||||
const { shouldEmail, waText } = require('../utils/notify');
|
||||
const { buildEventNotifyRecipientList } = require('../utils/notifications');
|
||||
const given = request.status === 'given';
|
||||
const verb = given ? 'given' : 'declined';
|
||||
const who = request.attendee.name;
|
||||
const ev = request.event.title;
|
||||
const payer = request.attendee.registration.user;
|
||||
const line = given
|
||||
? `${request.signedName} gave parental consent for ${who} for ${ev}.`
|
||||
: `${request.signedName} declined parental consent for ${who} for ${ev}. ${who} can't be checked in — please contact the organisers about a refund.`;
|
||||
|
||||
const sends = [];
|
||||
const payerHasEmail = payer?.email && !payer.email.endsWith('@guest.local') && !payer.email.endsWith('@deleted.invalid');
|
||||
if (payerHasEmail && shouldEmail(payer) && payer.email !== request.parentEmail) {
|
||||
sends.push(sendMail({ to: payer.email, subject: `Consent ${verb} for ${who}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
|
||||
}
|
||||
if (payer) sends.push(waText(payer, line));
|
||||
const staff = buildEventNotifyRecipientList(request.event);
|
||||
if (staff.length) {
|
||||
sends.push(sendMail({ to: staff.join(','), subject: `Consent ${verb}: ${who} — ${ev}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
|
||||
}
|
||||
await Promise.allSettled(sends);
|
||||
}
|
||||
|
||||
/** One row per attendee on the event, with consent state — for the consent tracker. */
|
||||
async function consentRows(eventId) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) return { event: null, rows: [] };
|
||||
const attendees = await prisma.attendee.findMany({
|
||||
where: { registration: { eventId, status: { not: 'cancelled' } } },
|
||||
include: { registration: { select: { id: true, status: true, user: { select: { id: true, name: true, email: true } } } } },
|
||||
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
|
||||
});
|
||||
const latest = await latestRequestsFor(attendees.map(a => a.id));
|
||||
const rows = attendees.map(a => {
|
||||
const r = latest.get(a.id) || null;
|
||||
return {
|
||||
attendeeId: a.id,
|
||||
name: a.name,
|
||||
dateOfBirth: a.dateOfBirth,
|
||||
minor: isMinor(a.dateOfBirth, event.startDate),
|
||||
parentName: a.parentName, parentEmail: a.parentEmail, parentPhone: a.parentPhone,
|
||||
registrationId: a.registrationId,
|
||||
registrationStatus: a.registration.status,
|
||||
bookedBy: a.registration.user,
|
||||
status: consentStatusFor(a, event, r),
|
||||
request: r && {
|
||||
id: r.id, status: r.status, textVersion: r.textVersion, signedName: r.signedName, respondedAt: r.respondedAt,
|
||||
lastSentAt: r.lastSentAt, remindersSent: r.remindersSent, photoConsent: r.photoConsent,
|
||||
outdated: r.status === 'given' && r.textVersion < (event.consentTextVersion || 1),
|
||||
},
|
||||
};
|
||||
});
|
||||
return { event, rows };
|
||||
}
|
||||
|
||||
// @desc Consent status for every attendee on an event
|
||||
// @route GET /api/consent/events/:eventId
|
||||
// @access Private/Staff
|
||||
const listEventConsent = async (req, res) => {
|
||||
try {
|
||||
const { event, rows } = await consentRows(req.params.eventId);
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const counts = rows.reduce((c, r) => { c[r.status] = (c[r.status] || 0) + 1; return c; }, {});
|
||||
res.json({
|
||||
event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion },
|
||||
counts,
|
||||
rows,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-send one consent request (with a fresh link)
|
||||
// @route POST /api/consent/requests/:id/resend
|
||||
// @access Private (the registrant, or staff)
|
||||
const resendRequest = async (req, res) => {
|
||||
try {
|
||||
const request = await prisma.consentRequest.findUnique({ where: { id: req.params.id }, include: { attendee: { include: { registration: true } } } });
|
||||
if (!request) { res.status(404); throw new Error('Consent request not found'); }
|
||||
if (request.attendee.registration.userId !== req.user.id && !STAFF.includes(req.user.role)) { res.status(403); throw new Error('Not authorized'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
if (latest?.id !== request.id) { res.status(400); throw new Error('A newer consent request has replaced this one.'); }
|
||||
const sent = await resendConsentRequest(request.id);
|
||||
res.json({ message: sent.length ? `Consent request re-sent by ${sent.join(' and ')}.` : 'The consent request could not be sent — check the parent\'s email and phone.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-send every consent request still pending on an event
|
||||
// @route POST /api/consent/events/:eventId/resend-pending
|
||||
// @access Private/Supervisor
|
||||
const resendPending = async (req, res) => {
|
||||
try {
|
||||
const { rows } = await consentRows(req.params.eventId);
|
||||
const pending = rows.filter(r => r.status === 'pending' && r.request);
|
||||
let sent = 0;
|
||||
for (const r of pending) {
|
||||
try { if ((await resendConsentRequest(r.request.id)).length) sent += 1; } catch {}
|
||||
}
|
||||
res.json({ message: `Re-sent ${sent} of ${pending.length} pending request(s).`, sent, pending: pending.length });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Ask again for consent where it was given on an older version of the text,
|
||||
// and for minors who never got a request (e.g. names added later)
|
||||
// @route POST /api/consent/events/:eventId/rerequest
|
||||
// @access Private/Supervisor
|
||||
const rerequestConsent = async (req, res) => {
|
||||
try {
|
||||
const { event, rows } = await consentRows(req.params.eventId);
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
if (!event.requiresParentalConsent) { res.status(400); throw new Error('This event does not need parental consent.'); }
|
||||
const targets = rows.filter(r => r.minor && r.registrationStatus !== 'cancelled' && (r.status === 'missing' || r.request?.outdated));
|
||||
let created = 0;
|
||||
for (const r of targets) {
|
||||
if (!r.parentEmail && !r.parentPhone) continue;
|
||||
const attendee = await prisma.attendee.findUnique({ where: { id: r.attendeeId } });
|
||||
await createConsentRequest(attendee, event);
|
||||
created += 1;
|
||||
}
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'consent_rerequested',
|
||||
targetType: 'Event', targetId: event.id, metadata: { requests: created, textVersion: event.consentTextVersion },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ message: `Sent ${created} new consent request(s).`, created });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Medical & emergency list for an event (decrypted) — every view is audited
|
||||
// @route GET /api/consent/events/:eventId/medical
|
||||
// @access Private/Supervisor
|
||||
const medicalList = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId } });
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const { rows } = await consentRows(event.id);
|
||||
const ids = rows.map(r => r.request?.id).filter(Boolean);
|
||||
const full = ids.length ? await prisma.consentRequest.findMany({ where: { id: { in: ids } } }) : [];
|
||||
const byId = new Map(full.map(r => [r.id, r]));
|
||||
const list = rows
|
||||
.filter(r => r.request && byId.get(r.request.id)?.status === 'given')
|
||||
.map(r => {
|
||||
const fr = byId.get(r.request.id);
|
||||
return {
|
||||
name: r.name, dateOfBirth: r.dateOfBirth,
|
||||
parentName: r.parentName, parentPhone: r.parentPhone, parentEmail: r.parentEmail,
|
||||
medical: openJson(fr.medical), emergency: openJson(fr.emergency), photoConsent: fr.photoConsent,
|
||||
purged: !!fr.medicalPurgedAt,
|
||||
};
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed',
|
||||
targetType: 'Event', targetId: event.id, metadata: { attendees: list.length },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ event: { id: event.id, title: event.title, startDate: event.startDate }, rows: list });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList };
|
||||
@@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) {
|
||||
// @access Private/Admin
|
||||
const createEvent = async (req, res) => {
|
||||
try {
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
@@ -62,6 +62,21 @@ const createEvent = async (req, res) => {
|
||||
isHidden: isHidden === true || isHidden === 'true',
|
||||
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
|
||||
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
|
||||
// A youth event needs names (with dates of birth), so it always takes them.
|
||||
allowNamedAttendees: allowNamedAttendees === true || allowNamedAttendees === 'true' || requiresParentalConsent === true,
|
||||
requiresParentalConsent: requiresParentalConsent === true,
|
||||
consentText: consentText ? String(consentText) : null,
|
||||
consentCollectMedical: consentCollectMedical === true,
|
||||
consentCollectEmergency: consentCollectEmergency === true,
|
||||
consentCollectPhoto: consentCollectPhoto === true,
|
||||
waitlistEnabled: waitlistEnabled === true,
|
||||
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,
|
||||
contactPhone: contactPhone || null,
|
||||
contactEmail: contactEmail || null,
|
||||
@@ -212,12 +227,8 @@ const getEvents = async (req, res) => {
|
||||
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
|
||||
let isSoldOut = false;
|
||||
if (limitedOpts.length > 0) {
|
||||
const soldCounts = await Promise.all(limitedOpts.map(opt =>
|
||||
prisma.registrationOption.aggregate({
|
||||
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true },
|
||||
}).then(r => r._sum?.quantity || 0)
|
||||
));
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id })));
|
||||
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
|
||||
}
|
||||
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut };
|
||||
@@ -418,22 +429,15 @@ const getEventById = async (req, res) => {
|
||||
try {
|
||||
if (canIncludeVariants) {
|
||||
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
|
||||
// Total sold quantity for this option (all non-cancelled)
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const soldCount = soldAgg._sum?.quantity || 0;
|
||||
// Taken = non-cancelled registrations + places held for live waitlist offers
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const soldCount = await takenCount({ eventOptionId: opt.id });
|
||||
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
|
||||
|
||||
// Per-variant stock
|
||||
const variantsWithStock = await Promise.all((opt.variants || []).map(async v => {
|
||||
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
|
||||
const vAgg = await prisma.registrationOption.aggregate({
|
||||
where: { variantId: v.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const vSold = vAgg._sum?.quantity || 0;
|
||||
const vSold = await takenCount({ eventOptionId: opt.id, variantId: v.id });
|
||||
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
|
||||
}));
|
||||
|
||||
@@ -537,7 +541,7 @@ const updateEvent = async (req, res) => {
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
@@ -552,6 +556,26 @@ const updateEvent = async (req, res) => {
|
||||
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
|
||||
requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true),
|
||||
requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true),
|
||||
allowNamedAttendees: (requiresParentalConsent ?? event.requiresParentalConsent) === true
|
||||
? true
|
||||
: (allowNamedAttendees !== undefined ? (allowNamedAttendees === true || allowNamedAttendees === 'true') : event.allowNamedAttendees),
|
||||
requiresParentalConsent: requiresParentalConsent !== undefined ? requiresParentalConsent === true : event.requiresParentalConsent,
|
||||
consentCollectMedical: consentCollectMedical !== undefined ? consentCollectMedical === true : event.consentCollectMedical,
|
||||
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
|
||||
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
|
||||
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
|
||||
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
|
||||
// their record of the old one (supervisors can re-request consent).
|
||||
...(consentText !== undefined && String(consentText || '') !== String(event.consentText || '')
|
||||
? { consentText: consentText ? String(consentText) : null, consentTextVersion: (event.consentTextVersion || 1) + 1 }
|
||||
: {}),
|
||||
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
|
||||
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
|
||||
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
|
||||
@@ -880,6 +904,10 @@ const updateEventOption = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (stockLimit !== undefined || variants !== undefined) {
|
||||
require('../utils/waitlist').queueWaitlist(eventOption.eventId);
|
||||
}
|
||||
|
||||
const refreshed = await prisma.eventOption.findUnique({
|
||||
where: { id: updatedEventOption.id },
|
||||
include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } }
|
||||
@@ -1311,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) {
|
||||
@@ -1361,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) });
|
||||
}
|
||||
@@ -1549,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 };
|
||||
|
||||
@@ -8,6 +8,42 @@ const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
|
||||
const { lineKey, validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
|
||||
const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { ensureConsentRequests } = require('../utils/consent');
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const { findOfferByToken, queueWaitlist } = require('../utils/waitlist');
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
function queueConsentRequests(registrationId) {
|
||||
ensureConsentRequests(registrationId).catch(e => console.error('[consent] Failed to send requests:', e?.message || e));
|
||||
}
|
||||
|
||||
// Named attendees for API responses, with whether their ticket has been issued/used.
|
||||
const ATTENDEES_INCLUDE = {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ticket: { select: { id: true, isUsed: true } },
|
||||
// Latest consent request (youth events) — it decides check-in.
|
||||
consentRequests: { orderBy: { createdAt: 'desc' }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
|
||||
},
|
||||
};
|
||||
|
||||
const TICKET_DELIVERY = ['payer', 'each'];
|
||||
|
||||
/**
|
||||
* Places each line will have once `requested` is added to `existingLines` — used to
|
||||
* check attendee names against what's actually being bought.
|
||||
*/
|
||||
function placesAfter(existingLines, requested) {
|
||||
const out = {};
|
||||
for (const l of [...(existingLines || []), ...(requested || [])]) {
|
||||
const k = lineKey(l.eventOptionId, l.variantId);
|
||||
out[k] = (out[k] || 0) + Number(l.quantity || 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Active coupon on a registration, for API responses (the discount itself is
|
||||
// Registration.discountTotal).
|
||||
@@ -50,33 +86,22 @@ async function priceRequestedOption(eventOption, variantId, qty, buyer) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check overall stock availability for an EventOption.
|
||||
* Check overall stock availability for an EventOption. Places held for live waitlist
|
||||
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
||||
* Returns { available: boolean, remaining: number|null }
|
||||
*/
|
||||
async function checkOptionStock(option, requestedQty) {
|
||||
async function checkOptionStock(option, requestedQty, excludeEntryId = null) {
|
||||
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
|
||||
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
where: { eventOptionId: option.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const sold = soldAgg._sum?.quantity || 0;
|
||||
const remaining = option.stockLimit - sold;
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check stock availability for a specific variant.
|
||||
* Check stock availability for a specific variant (same rules as checkOptionStock).
|
||||
*/
|
||||
async function checkVariantStock(variant, requestedQty) {
|
||||
async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
|
||||
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
|
||||
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
where: { variantId: variant.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const sold = soldAgg._sum?.quantity || 0;
|
||||
const remaining = variant.stockLimit - sold;
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
@@ -85,7 +110,7 @@ async function checkVariantStock(variant, requestedQty) {
|
||||
// @access Private
|
||||
const createRegistration = async (req, res) => {
|
||||
try {
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode } = req.body;
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken } = req.body;
|
||||
|
||||
// Check if event exists
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
@@ -113,15 +138,26 @@ const createRegistration = async (req, res) => {
|
||||
throw new Error('This event is closed and no longer accepting registrations.');
|
||||
}
|
||||
|
||||
// A waitlist claim link: its token identifies the person and releases the places
|
||||
// held for them. It works without logging in, the same way a consent link does.
|
||||
let waitlistOffer = null;
|
||||
if (waitlistToken) {
|
||||
waitlistOffer = await findOfferByToken(waitlistToken);
|
||||
if (!waitlistOffer || waitlistOffer.eventId !== eventId) { res.status(400); throw new Error('This waitlist link is not valid.'); }
|
||||
if (waitlistOffer.status !== 'offered') { res.status(400); throw new Error(`This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`); }
|
||||
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) { res.status(400); throw new Error('This waitlist offer has expired.'); }
|
||||
if (req.user && req.user.id !== waitlistOffer.userId) { res.status(403); throw new Error('This waitlist offer belongs to someone else.'); }
|
||||
}
|
||||
|
||||
// Enforce auth unless event explicitly opts out
|
||||
const eventRequiresAuth = event.requiresAuth !== false;
|
||||
if (eventRequiresAuth && !req.user) {
|
||||
if (eventRequiresAuth && !req.user && !waitlistOffer) {
|
||||
res.status(401);
|
||||
throw new Error('Authentication required for this event');
|
||||
}
|
||||
|
||||
// Unauthenticated guest registration is only permitted for free events
|
||||
if (!req.user) {
|
||||
if (!req.user && !waitlistOffer) {
|
||||
const totalDueCheck = (event.eventOptions || []).reduce((sum, eo) => sum + (eo.price || 0), 0);
|
||||
// Also check against the requested options specifically
|
||||
const requestedTotal = (options || []).reduce((sum, opt) => {
|
||||
@@ -138,13 +174,20 @@ const createRegistration = async (req, res) => {
|
||||
let userId;
|
||||
if (req.user) {
|
||||
userId = req.user.id;
|
||||
} else if (waitlistOffer) {
|
||||
userId = waitlistOffer.userId;
|
||||
} else {
|
||||
// Guest registration: find existing user by email or create inactive placeholder
|
||||
if (!guestEmail || !guestName) {
|
||||
res.status(400);
|
||||
throw new Error('Name and email are required for unauthenticated registration');
|
||||
}
|
||||
const existingGuest = await prisma.user.findUnique({ where: { email: guestEmail } });
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) {
|
||||
res.status(409);
|
||||
throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.');
|
||||
}
|
||||
const existingGuest = match.user;
|
||||
if (existingGuest) {
|
||||
userId = existingGuest.id;
|
||||
} else {
|
||||
@@ -195,7 +238,7 @@ const createRegistration = async (req, res) => {
|
||||
|
||||
// Check overall option stock
|
||||
try {
|
||||
const stockCheck = await checkOptionStock(eventOption, qty);
|
||||
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id);
|
||||
if (!stockCheck.available) {
|
||||
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
@@ -215,7 +258,7 @@ const createRegistration = async (req, res) => {
|
||||
throw new Error(`Variant not found for option "${eventOption.name}"`);
|
||||
}
|
||||
try {
|
||||
const vStock = await checkVariantStock(variant, qty);
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id);
|
||||
if (!vStock.available) {
|
||||
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
@@ -232,7 +275,8 @@ const createRegistration = async (req, res) => {
|
||||
let priceSnapshot = null;
|
||||
let appliedTierId = null;
|
||||
if (canIncludeTiers) {
|
||||
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, req.user));
|
||||
const buyer = req.user || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
|
||||
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, buyer));
|
||||
} else {
|
||||
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
|
||||
}
|
||||
@@ -254,6 +298,19 @@ const createRegistration = async (req, res) => {
|
||||
include: { registrationOptions: true }
|
||||
});
|
||||
|
||||
// Attendee names are checked against the places the registration will have after
|
||||
// this purchase, counting names already given on an existing registration.
|
||||
const cleanAttendees = validateAttendees(
|
||||
event,
|
||||
placesAfter(existingReg?.registrationOptions, resolvedOptions),
|
||||
attendees,
|
||||
existingReg ? await namedPlacesByKey(existingReg.id) : {}
|
||||
);
|
||||
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
|
||||
res.status(400);
|
||||
throw new Error("ticketDelivery must be 'payer' or 'each'");
|
||||
}
|
||||
|
||||
let registration;
|
||||
let isNewRegistration = false;
|
||||
if (existingReg) {
|
||||
@@ -358,6 +415,20 @@ const createRegistration = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (cleanAttendees.length > 0) {
|
||||
await createAttendees(registration.id, cleanAttendees);
|
||||
queueConsentRequests(registration.id);
|
||||
}
|
||||
if (waitlistOffer) {
|
||||
await prisma.waitlistEntry.update({
|
||||
where: { id: waitlistOffer.id },
|
||||
data: { status: 'claimed', claimedAt: new Date(), registrationId: registration.id, offerTokenHash: null },
|
||||
});
|
||||
}
|
||||
if (ticketDelivery) {
|
||||
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
||||
}
|
||||
|
||||
// Apply the coupon (or re-work an existing one over the merged items).
|
||||
let couponError = null;
|
||||
if (!isNewRegistration) await recalculateRegistrationDiscount(registration.id);
|
||||
@@ -370,7 +441,7 @@ const createRegistration = async (req, res) => {
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
if (couponCode || !isNewRegistration) {
|
||||
if (couponCode || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registration.id },
|
||||
include: {
|
||||
@@ -378,6 +449,7 @@ const createRegistration = async (req, res) => {
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -398,31 +470,9 @@ const createRegistration = async (req, res) => {
|
||||
hasRequiredForm = !!(form && form.isRequired);
|
||||
} catch {}
|
||||
if (!hasRequiredForm) {
|
||||
// Batch-fetch existing tickets then create/update in parallel (1 read + N parallel writes vs N×2 sequential)
|
||||
const optionIds = registration.registrationOptions.map(o => o.id);
|
||||
const existingTickets = await prisma.ticket.findMany({ where: { registrationOptionId: { in: optionIds } } });
|
||||
const ticketByOptionId = Object.fromEntries(existingTickets.map(t => [t.registrationOptionId, t]));
|
||||
await Promise.all(registration.registrationOptions.map(option => {
|
||||
const exists = ticketByOptionId[option.id];
|
||||
if (exists) {
|
||||
const newQty = option.quantity || 1;
|
||||
if (newQty > (exists.quantity || 1)) {
|
||||
return prisma.ticket.update({ where: { id: exists.id }, data: { quantity: newQty, updatedAt: new Date() } });
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
return prisma.ticket.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
qrCode: uuidv4(),
|
||||
registrationOptionId: option.id,
|
||||
userId: registration.userId,
|
||||
eventId: registration.eventId,
|
||||
quantity: option.quantity || 1,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
});
|
||||
}));
|
||||
// Same generator as paid registrations: a ticket per named attendee, and one
|
||||
// shared quantity ticket for the rest of each line.
|
||||
await generateTicketsForRegistration(registration.id);
|
||||
// Capture for chained email — tickets sent after registration confirmation
|
||||
freeTicketMockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
|
||||
freeTicketMockRes = { status: () => freeTicketMockRes, json: () => {} };
|
||||
@@ -466,6 +516,7 @@ const getRegistrations = async (req, res) => {
|
||||
try {
|
||||
const include = {
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
registrationOptions: {
|
||||
include: {
|
||||
@@ -544,6 +595,8 @@ const getUserRegistrations = async (req, res) => {
|
||||
}
|
||||
},
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
formResponses: { select: { id: true, attendeeId: true } },
|
||||
// Nest the event's form so the frontend can tell whether attendee forms are
|
||||
// required without a separate GET /api/events/:id per registration.
|
||||
event: { include: { form: { include: { fields: true } } } },
|
||||
@@ -592,6 +645,7 @@ const getRegistrationById = async (req, res) => {
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
formResponses: { include: { answers: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
};
|
||||
|
||||
let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
|
||||
@@ -609,11 +663,17 @@ const getRegistrationById = async (req, res) => {
|
||||
}
|
||||
|
||||
// Guests (no auth) can view by knowing the registrationId (UUID = unguessable)
|
||||
// Authenticated users must be the owner or staff+
|
||||
if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') {
|
||||
// Authenticated users must be the owner, staff+, or a named attendee on it
|
||||
const isOwnerOrStaffViewer = req.user && (registration.userId === req.user.id || ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
const isAttendeeViewer = req.user && (registration.attendees || []).some(a => a.userId === req.user.id);
|
||||
if (req.user && !isOwnerOrStaffViewer && !isAttendeeViewer) {
|
||||
res.status(403);
|
||||
throw new Error('Not authorized to view this registration');
|
||||
}
|
||||
// Only the payer and staff see attendees' contact details.
|
||||
if (!isOwnerOrStaffViewer) {
|
||||
registration.attendees = (registration.attendees || []).map(a => ({ ...a, email: null, phone: null }));
|
||||
}
|
||||
|
||||
res.json(attachComputedTotals(registration));
|
||||
} catch (error) {
|
||||
@@ -689,6 +749,7 @@ const updateRegistrationStatus = async (req, res) => {
|
||||
// paths land under the one 'registration_cancelled' filter in the audit log.
|
||||
if (status === 'cancelled' && registration.status !== 'cancelled') {
|
||||
await releaseRegistrationCoupons(req.params.id);
|
||||
queueWaitlist(registration.eventId);
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
@@ -774,6 +835,7 @@ const cancelRegistration = async (req, res) => {
|
||||
}
|
||||
});
|
||||
await releaseRegistrationCoupons(req.params.id);
|
||||
queueWaitlist(registration.eventId);
|
||||
|
||||
// Only log when staff cancels on someone else's behalf — a routine self-service
|
||||
// cancellation isn't an admin action worth cluttering the audit trail with.
|
||||
@@ -826,6 +888,7 @@ const getRegistrationsByEvent = async (req, res) => {
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
@@ -851,7 +914,7 @@ const getRegistrationsByEvent = async (req, res) => {
|
||||
const createManualRegistration = async (req, res) => {
|
||||
let userRecord;
|
||||
try {
|
||||
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice, couponCode } = req.body;
|
||||
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice, couponCode, attendees, ticketDelivery } = req.body;
|
||||
|
||||
if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) {
|
||||
res.status(400);
|
||||
@@ -882,7 +945,8 @@ const createManualRegistration = async (req, res) => {
|
||||
throw new Error('At least one option must be selected');
|
||||
}
|
||||
|
||||
// Validate each option up front, before any account is created or changed.
|
||||
// Validate each option up front, before any account is created or changed —
|
||||
// including stock, which staff registrations previously skipped.
|
||||
for (const option of options) {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption) {
|
||||
@@ -893,6 +957,14 @@ const createManualRegistration = async (req, res) => {
|
||||
res.status(400);
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
const variant = option.variantId ? (eventOption.variants || []).find(v => v.id === option.variantId) : null;
|
||||
const oStock = await checkOptionStock(eventOption, option.quantity);
|
||||
const vStock = variant ? await checkVariantStock(variant, option.quantity) : { available: true };
|
||||
if (!oStock.available || !vStock.available) {
|
||||
res.status(400);
|
||||
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
|
||||
throw new Error(`"${eventOption.name}${variant ? ` (${variant.name})` : ''}" is sold out or does not have enough stock (${left} remaining). Raise the stock limit to add more.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing (appliedTierId + priceSnapshot) depends on who the attendee is — Staff and
|
||||
@@ -1049,6 +1121,17 @@ const createManualRegistration = async (req, res) => {
|
||||
include: { registrationOptions: true }
|
||||
});
|
||||
|
||||
const cleanAttendees = validateAttendees(
|
||||
event,
|
||||
placesAfter(existingReg?.registrationOptions, resolvedManualOptions),
|
||||
attendees,
|
||||
existingReg ? await namedPlacesByKey(existingReg.id) : {}
|
||||
);
|
||||
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
|
||||
res.status(400);
|
||||
throw new Error("ticketDelivery must be 'payer' or 'each'");
|
||||
}
|
||||
|
||||
let registration;
|
||||
let isNewRegistration = false;
|
||||
|
||||
@@ -1194,6 +1277,12 @@ const createManualRegistration = async (req, res) => {
|
||||
|
||||
const registrationId = registration.id;
|
||||
|
||||
if (cleanAttendees.length > 0) {
|
||||
await createAttendees(registrationId, cleanAttendees);
|
||||
queueConsentRequests(registrationId);
|
||||
}
|
||||
if (ticketDelivery) await prisma.registration.update({ where: { id: registrationId }, data: { ticketDelivery } });
|
||||
|
||||
if (couponCode && !couponError) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
@@ -1441,6 +1530,15 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
let removalPlan = null;
|
||||
|
||||
if (delta > 0) {
|
||||
// Adding places needs stock, like a new registration does.
|
||||
const oStock = await checkOptionStock(eventOption, delta);
|
||||
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
|
||||
const vStock = variant ? await checkVariantStock(variant, delta) : { available: true };
|
||||
if (!oStock.available || !vStock.available) {
|
||||
res.status(400);
|
||||
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
|
||||
throw new Error(`Only ${left} more "${eventOption.name}${variant ? ` (${variant.name})` : ''}" available.`);
|
||||
}
|
||||
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, delta, registration.user));
|
||||
newTotalDue += existingTotal + delta * (priceSnapshot || 0);
|
||||
} else if (delta < 0) {
|
||||
@@ -1573,6 +1671,18 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Names on places that no longer exist go too (newest first). Tickets for them
|
||||
// are tidied up by generateTicketsForRegistration; the floor check above already
|
||||
// stops anything going below tickets issued.
|
||||
await tx.attendee.deleteMany({ where: { registrationId, registrationOptionId: null } });
|
||||
const lines = await tx.registrationOption.findMany({ where: { registrationId }, include: { attendees: { orderBy: { order: 'desc' } } } });
|
||||
for (const line of lines) {
|
||||
const extra = (line.attendees || []).length - (line.quantity || 0);
|
||||
if (extra > 0) {
|
||||
await tx.attendee.deleteMany({ where: { id: { in: line.attendees.slice(0, extra).map(a => a.id) } } });
|
||||
}
|
||||
}
|
||||
|
||||
// Coupon: percentage/comp codes follow the new items, fixed codes keep their amount.
|
||||
const discount = await recalculateRegistrationDiscount(registrationId, tx);
|
||||
const dueAfterDiscount = Math.max(0, newTotalDue - discount);
|
||||
@@ -1601,11 +1711,13 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
payments: true,
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
}
|
||||
});
|
||||
|
||||
const { sendRegistrationUpdatedEmails } = require('../utils/notifications');
|
||||
sendRegistrationUpdatedEmails(registrationId).catch(e => console.error('Failed to send registration updated emails:', e));
|
||||
if (resolvedUpdateOptions.some(o => o.delta < 0) || oldByKey.size > newKeys.size) queueWaitlist(registration.eventId);
|
||||
|
||||
// If registration is now paid, regenerate tickets and send them
|
||||
if (updated.status === 'paid') {
|
||||
@@ -1645,8 +1757,11 @@ const submitFormResponses = async (req, res) => {
|
||||
}
|
||||
});
|
||||
if (!registration) { res.status(404); throw new Error('Registration not found'); }
|
||||
// Guests (no auth) may submit by knowing the registrationId; authenticated users must be owner or staff+
|
||||
if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role)) {
|
||||
// Guests (no auth) may submit by knowing the registrationId; authenticated users must be
|
||||
// owner, staff+, or submitting their own form as a named attendee
|
||||
const ownAttendeeOnly = Array.isArray(responses) && responses.length > 0 && req.user
|
||||
&& (await prisma.attendee.count({ where: { registrationId, userId, id: { in: responses.map(r => r?.attendeeId).filter(Boolean) } } })) === responses.length;
|
||||
if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role) && !ownAttendeeOnly) {
|
||||
res.status(403); throw new Error('Not authorized');
|
||||
}
|
||||
|
||||
@@ -1668,11 +1783,20 @@ const submitFormResponses = async (req, res) => {
|
||||
if (toSubmit.length === 0) { res.status(400); throw new Error('No responses provided'); }
|
||||
if (toSubmit.length > mainTickets) { res.status(400); throw new Error(`Too many forms submitted. Expected at most ${mainTickets}`); }
|
||||
|
||||
// A response can be for a named attendee (group tickets); each attendee answers once.
|
||||
const attendeeIds = toSubmit.map(r => r?.attendeeId).filter(Boolean);
|
||||
if (attendeeIds.length > 0) {
|
||||
const known = await prisma.attendee.findMany({ where: { id: { in: attendeeIds }, registrationId }, include: { formResponses: { select: { id: true } } } });
|
||||
if (known.length !== new Set(attendeeIds).size) { res.status(400); throw new Error('Unknown attendee for this registration'); }
|
||||
const answered = known.find(a => a.formResponses.length > 0);
|
||||
if (answered) { res.status(400); throw new Error(`${answered.name}'s form has already been submitted`); }
|
||||
}
|
||||
|
||||
// Create responses and answers
|
||||
const created = [];
|
||||
for (const resp of toSubmit) {
|
||||
const respId = uuidv4();
|
||||
const fr = await prisma.formResponse.create({ data: { id: respId, registrationId } });
|
||||
const fr = await prisma.formResponse.create({ data: { id: respId, registrationId, attendeeId: resp?.attendeeId || null } });
|
||||
const ansMap = resp?.answers || {};
|
||||
for (const f of answerableFields) {
|
||||
const raw = ansMap[f.id];
|
||||
@@ -2044,7 +2168,116 @@ const removeCoupon = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isOwnerOrStaff = (registration, user) =>
|
||||
registration.userId === user.id || ['admin', 'supervisor', 'staff'].includes(user.role);
|
||||
|
||||
// @desc Replace the named attendees on a registration (and who gets the tickets)
|
||||
// @route PUT /api/registrations/:id/attendees
|
||||
// @access Private (owner or staff)
|
||||
// Body: { attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }
|
||||
// Attendees left out are removed — refused if their ticket has been scanned.
|
||||
const updateAttendees = async (req, res) => {
|
||||
try {
|
||||
const { attendees = [], ticketDelivery } = req.body || {};
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
registrationOptions: true,
|
||||
attendees: { include: { ticket: { include: { usages: true } } } },
|
||||
event: { include: { eventOptions: true } },
|
||||
},
|
||||
});
|
||||
if (!registration) { res.status(404); throw new Error('Registration not found'); }
|
||||
if (!isOwnerOrStaff(registration, req.user)) { res.status(403); throw new Error('Not authorized to edit this registration'); }
|
||||
if (registration.status === 'cancelled') { res.status(400); throw new Error('This registration is cancelled.'); }
|
||||
if (registration.event?.cashupStatus === 'closed') { res.status(400); throw new Error('This event is closed.'); }
|
||||
if (registration.event?.endDate && new Date(registration.event.endDate) < new Date()) {
|
||||
res.status(400); throw new Error('This event has already ended.');
|
||||
}
|
||||
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
|
||||
res.status(400); throw new Error("ticketDelivery must be 'payer' or 'each'");
|
||||
}
|
||||
|
||||
const clean = validateAttendees(registration.event, placesAfter(registration.registrationOptions, []), attendees);
|
||||
const keepIds = new Set(attendees.map(a => a?.id).filter(Boolean));
|
||||
const existingById = new Map(registration.attendees.map(a => [a.id, a]));
|
||||
for (const id of keepIds) if (!existingById.has(id)) { res.status(400); throw new Error('Unknown attendee for this registration'); }
|
||||
const removed = registration.attendees.filter(a => !keepIds.has(a.id));
|
||||
const usedRemoved = removed.find(a => a.ticket && (a.ticket.isUsed || a.ticket.usages.length > 0));
|
||||
if (usedRemoved) { res.status(400); throw new Error(`${usedRemoved.name}'s ticket has already been scanned, so they can't be removed.`); }
|
||||
|
||||
const toCreate = [];
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (removed.length) await tx.attendee.deleteMany({ where: { id: { in: removed.map(a => a.id) } } });
|
||||
for (let i = 0; i < attendees.length; i++) {
|
||||
const a = attendees[i];
|
||||
const c = clean[i];
|
||||
if (a.id) {
|
||||
const prev = existingById.get(a.id);
|
||||
const contactChanged = prev.email !== c.email || prev.phone !== c.phone;
|
||||
const match = contactChanged && (c.email || c.phone) ? await findAccountByContact({ email: c.email, phone: c.phone }, tx) : null;
|
||||
await tx.attendee.update({
|
||||
where: { id: a.id },
|
||||
data: {
|
||||
name: c.name, email: c.email, phone: c.phone,
|
||||
dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: c.parentName, parentEmail: c.parentEmail, parentPhone: c.parentPhone,
|
||||
...(contactChanged ? { userId: match?.user?.id || null } : {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toCreate.push(c);
|
||||
}
|
||||
}
|
||||
if (toCreate.length) await createAttendees(registration.id, toCreate, tx);
|
||||
if (ticketDelivery) await tx.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
||||
});
|
||||
|
||||
queueConsentRequests(registration.id);
|
||||
|
||||
// Paid registrations: re-sync tickets and send any new ones.
|
||||
if (registration.status === 'paid') {
|
||||
const created = await generateTicketsForRegistration(registration.id);
|
||||
if (created.length > 0) {
|
||||
const mockReq = { user: { id: registration.userId }, body: { ticketIds: created.map(t => t.id) } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
emailTickets(mockReq, mockRes).catch(e => console.error('[attendees] Failed to send new tickets:', e?.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.registration.findUnique({
|
||||
where: { id: registration.id },
|
||||
select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE },
|
||||
});
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send an attendee a link to fill in their own attendee form
|
||||
// @route POST /api/registrations/:id/attendees/:attendeeId/send-form-link
|
||||
// @access Private (owner or staff)
|
||||
const sendAttendeeFormLink = async (req, res) => {
|
||||
try {
|
||||
const attendee = await prisma.attendee.findFirst({
|
||||
where: { id: req.params.attendeeId, registrationId: req.params.id },
|
||||
include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } },
|
||||
});
|
||||
if (!attendee) { res.status(404); throw new Error('Attendee not found'); }
|
||||
if (!isOwnerOrStaff(attendee.registration, req.user)) { res.status(403); throw new Error('Not authorized'); }
|
||||
if (!attendee.email && !attendee.phone) { res.status(400); throw new Error(`Add an email or phone number for ${attendee.name} first.`); }
|
||||
|
||||
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
|
||||
const sent = await sendAttendeeFormLinkMessage(attendee);
|
||||
res.json({ message: sent.length ? `Form link sent to ${attendee.name} by ${sent.join(' and ')}.` : 'The form link could not be sent.' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
updateAttendees,
|
||||
sendAttendeeFormLink,
|
||||
applyCoupon,
|
||||
removeCoupon,
|
||||
createRegistration,
|
||||
|
||||
@@ -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 };
|
||||
@@ -81,9 +81,10 @@ const getTickets = async (req, res) => {
|
||||
// @access Private
|
||||
const getUserTickets = async (req, res) => {
|
||||
try {
|
||||
// Tickets this user paid for, plus named tickets someone else bought for them.
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
OR: [{ userId: req.user.id }, { holderUserId: req.user.id }],
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
||||
},
|
||||
include: {
|
||||
@@ -94,6 +95,13 @@ const getUserTickets = async (req, res) => {
|
||||
registration: 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,
|
||||
usages: {
|
||||
include: {
|
||||
@@ -261,6 +269,7 @@ const getScanPreview = async (req, res) => {
|
||||
e.title AS "eventTitle",
|
||||
u.id AS "userId",
|
||||
u.name AS "userName",
|
||||
a.name AS "attendeeName",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
@@ -276,22 +285,29 @@ const getScanPreview = async (req, res) => {
|
||||
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "User" u ON u.id = t."userId"
|
||||
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id
|
||||
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
|
||||
const transfer = await findTransferByOldCode(req.params.qrCode);
|
||||
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
const { consentStatusForTickets } = require('../utils/consent');
|
||||
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
|
||||
res.json({
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
consentStatus,
|
||||
registrationOption: {
|
||||
id: r.registrationOptionId,
|
||||
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
|
||||
@@ -299,6 +315,7 @@ const getScanPreview = async (req, res) => {
|
||||
},
|
||||
event: { id: r.evId, title: r.eventTitle },
|
||||
user: { id: r.userId, name: r.userName },
|
||||
attendee: r.attendeeName ? { name: r.attendeeName } : null,
|
||||
usages: r.usages || []
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -342,6 +359,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');
|
||||
}
|
||||
@@ -363,6 +383,27 @@ const scanTicket = async (req, res) => {
|
||||
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
|
||||
}
|
||||
|
||||
// Section checks used to happen only in the browser; enforce them here too.
|
||||
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
|
||||
if (sectionId) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
|
||||
if (!section || section.eventId !== ticket.eventId) {
|
||||
return res.status(400).json({ message: 'Section not found for this event', ticket });
|
||||
}
|
||||
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
|
||||
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
|
||||
}
|
||||
}
|
||||
|
||||
// A section (or the whole event) locked to another device for offline scanning
|
||||
// can't be scanned online — those scans would be invisible to the offline device.
|
||||
const { blockingLock } = require('../utils/scanLocks');
|
||||
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
|
||||
if (lock) {
|
||||
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
|
||||
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
|
||||
}
|
||||
|
||||
// Compute how many have already been redeemed
|
||||
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
||||
@@ -375,6 +416,25 @@ const scanTicket = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Youth events: no check-in without parental consent, unless a supervisor overrides.
|
||||
const { consentStatusForTickets } = require('../utils/consent');
|
||||
const consentStatus = (await consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
|
||||
if (!['not_required', 'given'].includes(consentStatus)) {
|
||||
const canOverride = ['admin', 'supervisor'].includes(req.user?.role);
|
||||
if (!(req.body?.overrideConsent === true && canOverride)) {
|
||||
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
|
||||
return res.status(403).json({ message: `${why} — this ticket can't be checked in.`, code: 'CONSENT_REQUIRED', consentStatus, canOverride, ticket });
|
||||
}
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'consent_override',
|
||||
targetType: 'Ticket', targetId: ticket.id,
|
||||
metadata: { consentStatus, eventId: ticket.eventId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
// Determine how many to redeem this scan (defaults to all remaining)
|
||||
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
|
||||
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
|
||||
@@ -432,6 +492,8 @@ const getTicketsByEvent = async (req, res) => {
|
||||
include: {
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
attendee: { select: { id: true, name: true, email: true, phone: true } },
|
||||
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true } },
|
||||
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -641,6 +703,83 @@ const sendTicketsTo = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Transfer a ticket to someone else (new QR code; billing stays with the payer)
|
||||
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
|
||||
// @access Private (payer or holder; supervisors always)
|
||||
const transferTicketHandler = async (req, res) => {
|
||||
try {
|
||||
const { transferTicket } = require('../utils/ticketTransfer');
|
||||
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
|
||||
const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
|
||||
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
|
||||
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
// Tell the new holder: activation link for a new account, their ticket, and
|
||||
// anything they need to do themselves (consent, attendee form).
|
||||
if (r.accountCreated) {
|
||||
const { sendActivationLink } = require('./userController');
|
||||
sendActivationLink(r.toUser);
|
||||
}
|
||||
const a = r.attendee;
|
||||
const hasEmail = !!(a.email && a.email.includes('@'));
|
||||
const recipient = { id: r.toUser.id, name: a.name, email: hasEmail ? a.email : null, phoneNumber: a.phone || null, notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp' };
|
||||
const silent = { status: () => silent, json: () => silent };
|
||||
emailTicketsInternal({ user: { id: r.target.userId }, body: { ticketIds: [r.target.id] }, _noSplit: true }, silent, recipient)
|
||||
.catch(e => console.error('[transfer] ticket delivery failed:', e?.message || e));
|
||||
if (r.needsConsent) {
|
||||
const { createConsentRequest } = require('../utils/consent');
|
||||
createConsentRequest(a, r.event).catch(e => console.error('[transfer] consent request failed:', e?.message || e));
|
||||
}
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: r.event.id }, include: { fields: { select: { id: true } } } });
|
||||
if (form && form.fields.length > 0) {
|
||||
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
|
||||
const full = await prisma.attendee.findUnique({ where: { id: a.id }, include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } } });
|
||||
sendAttendeeFormLinkMessage(full).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `Ticket transferred to ${a.name}. We've sent them the new ticket${r.needsConsent ? ' and a parental consent request' : ''}. The old QR code no longer works.`,
|
||||
ticketId: r.target.id,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Transfer history of a ticket
|
||||
// @route GET /api/tickets/:id/transfers
|
||||
// @access Private (payer or holder; staff)
|
||||
const getTicketTransfers = async (req, res) => {
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id }, select: { userId: true, holderUserId: true } });
|
||||
if (!ticket) { res.status(404); throw new Error('Ticket not found'); }
|
||||
const staffRole = ['admin', 'supervisor', 'staff'].includes(req.user.role);
|
||||
if (!staffRole && ticket.userId !== req.user.id && ticket.holderUserId !== req.user.id) { res.status(403); throw new Error('Not authorized'); }
|
||||
const transfers = await prisma.ticketTransfer.findMany({
|
||||
where: { ticketId: req.params.id },
|
||||
select: { id: true, fromHolderName: true, toName: true, createdAt: true, byUser: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(transfers);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/** "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;
|
||||
@@ -660,6 +799,13 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
|
||||
}
|
||||
|
||||
const ticketInclude = {
|
||||
event: true,
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
|
||||
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
|
||||
user: { select: { name: true } },
|
||||
usages: true,
|
||||
};
|
||||
let tickets;
|
||||
if (registrationId) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
@@ -669,22 +815,53 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
const registrationOptions = await prisma.registrationOption.findMany({ where: { registrationId } });
|
||||
tickets = await prisma.ticket.findMany({
|
||||
where: { registrationOptionId: { in: registrationOptions.map(o => o.id) }, userId },
|
||||
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
|
||||
include: ticketInclude,
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
} else {
|
||||
// The payer, or the named holder of a group ticket, can have it sent.
|
||||
tickets = await prisma.ticket.findMany({
|
||||
where: { id: { in: ticketIds }, userId },
|
||||
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
|
||||
where: { id: { in: ticketIds }, OR: [{ userId }, { holderUserId: userId }] },
|
||||
include: ticketInclude,
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
// Dedup by registrationOptionId
|
||||
// Group tickets set to "send each person their own": attendees with contact details
|
||||
// get theirs directly; the rest (and attendees without contact details) go to the
|
||||
// payer below. Not when the caller asked for a specific recipient or channel.
|
||||
if (!userOverride && !req._noSplit) {
|
||||
const own = tickets.filter(t => t.attendee && (t.attendee.email || t.attendee.phone)
|
||||
&& t.registrationOption?.registration?.ticketDelivery === 'each');
|
||||
for (const t of own) {
|
||||
const a = t.attendee;
|
||||
const hasEmail = !!(a.email && a.email.includes('@'));
|
||||
const recipient = {
|
||||
id: a.userId || null,
|
||||
name: a.name,
|
||||
email: hasEmail ? a.email : null,
|
||||
phoneNumber: a.phone || null,
|
||||
notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp',
|
||||
};
|
||||
const silent = { status: () => silent, json: () => silent };
|
||||
try {
|
||||
await emailTicketsInternal({ user: { id: userId }, body: { ticketIds: [t.id] }, _noSplit: true }, silent, recipient);
|
||||
} catch (e) {
|
||||
console.error(`[tickets] Failed to send ${a.name}'s ticket:`, e?.message || e);
|
||||
}
|
||||
}
|
||||
const sentIds = new Set(own.map(t => t.id));
|
||||
tickets = tickets.filter(t => !sentIds.has(t.id));
|
||||
if (tickets.length === 0 && own.length > 0) {
|
||||
return res.json({ message: `${own.length} ticket(s) sent to the people they're for`, ticketIds: own.map(t => t.id) });
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup by registrationOptionId — except named tickets, which are one per person.
|
||||
{
|
||||
const seen = new Map(); const deduped = [];
|
||||
for (const t of tickets) {
|
||||
const key = t.registrationOptionId;
|
||||
const key = t.attendeeId ? `attendee:${t.attendeeId}` : t.registrationOptionId;
|
||||
if (!seen.has(key)) { seen.set(key, t); deduped.push(t); }
|
||||
else {
|
||||
const existing = seen.get(key);
|
||||
@@ -726,10 +903,11 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
doc.font('Helvetica').fontSize(10);
|
||||
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
|
||||
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
|
||||
doc.text(`Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
||||
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: ${user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
|
||||
doc.text(`Purchased by: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
|
||||
const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45;
|
||||
const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90;
|
||||
doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize });
|
||||
@@ -800,5 +978,7 @@ module.exports = {
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
getScanStats,
|
||||
transferTicketHandler,
|
||||
getTicketTransfers
|
||||
};
|
||||
@@ -888,6 +888,7 @@ const anonymizeUser = async (req, res) => {
|
||||
}
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.params.id);
|
||||
|
||||
res.json({ message: 'User data deleted' });
|
||||
} catch (error) {
|
||||
@@ -1149,6 +1150,7 @@ const closeAccount = async (req, res) => {
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.user.id);
|
||||
// Send closure confirmation to the real address (before it was wiped)
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { remainingFor } = require('../utils/stock');
|
||||
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken } = require('../utils/waitlist');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const ENTRY_INCLUDE = {
|
||||
event: { select: { id: true, title: true, startDate: true, picture: true } },
|
||||
eventOption: { select: { id: true, name: true } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
};
|
||||
|
||||
// @desc Join the waitlist for a sold-out option
|
||||
// @route POST /api/waitlist
|
||||
// @access Public (login needed when the event requires it)
|
||||
// Body: { eventId, eventOptionId, variantId?, quantity, guestName?, guestEmail?, guestPhone? }
|
||||
const joinWaitlist = async (req, res) => {
|
||||
try {
|
||||
const { eventId, eventOptionId, variantId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
|
||||
const qty = parseInt(quantity, 10);
|
||||
if (!eventId || !eventOptionId || !(qty >= 1)) { res.status(400); throw new Error('eventId, eventOptionId and a quantity of at least 1 are required'); }
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } } } });
|
||||
if (!event || !event.isActive) { res.status(404); throw new Error('Event not found'); }
|
||||
if (!event.waitlistEnabled) { res.status(400); throw new Error('This event does not have a waitlist.'); }
|
||||
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) { res.status(400); throw new Error('This event is no longer taking registrations.'); }
|
||||
const option = event.eventOptions.find(o => o.id === eventOptionId);
|
||||
if (!option) { res.status(400); throw new Error('Unknown ticket option for this event'); }
|
||||
const variant = variantId ? (option.variants || []).find(v => v.id === variantId) : null;
|
||||
if (variantId && !variant) { res.status(400); throw new Error('Unknown variant for this option'); }
|
||||
|
||||
const free = await remainingFor(option, variant);
|
||||
if (free === null || free >= qty) { res.status(400); throw new Error('There are places available — you can register now.'); }
|
||||
|
||||
// Who's joining
|
||||
let userId = req.user?.id;
|
||||
if (!userId) {
|
||||
if (event.requiresAuth !== false) { res.status(401); throw new Error('Log in to join the waitlist for this event.'); }
|
||||
if (!guestName || !guestEmail) { res.status(400); throw new Error('Name and email are required to join the waitlist'); }
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) { res.status(409); throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.'); }
|
||||
userId = match.user?.id || (await prisma.user.create({
|
||||
data: { id: uuidv4(), name: String(guestName).trim(), email: String(guestEmail).trim(), password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() },
|
||||
})).id;
|
||||
}
|
||||
|
||||
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, status: { in: ACTIVE } } });
|
||||
if (existing) { res.status(409); throw new Error('You\'re already on the waitlist for this.'); }
|
||||
|
||||
const last = await prisma.waitlistEntry.aggregate({ where: { eventId }, _max: { position: true } });
|
||||
const entry = await prisma.waitlistEntry.create({
|
||||
data: { eventId, eventOptionId, variantId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
|
||||
include: ENTRY_INCLUDE,
|
||||
});
|
||||
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, status: 'waiting', position: { lt: entry.position } } });
|
||||
res.status(201).json({ ...entry, ahead, message: `You're on the waitlist${ahead ? ` — ${ahead} ahead of you` : ''}. We'll email or WhatsApp you if a place opens up.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The caller's waitlist entries
|
||||
// @route GET /api/waitlist/mine
|
||||
// @access Private
|
||||
const myWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entries = await prisma.waitlistEntry.findMany({
|
||||
where: { userId: req.user.id, status: { in: ACTIVE }, event: { endDate: { gte: new Date() } } },
|
||||
include: ENTRY_INCLUDE,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Leave the waitlist (an open offer is passed on)
|
||||
// @route DELETE /api/waitlist/:id
|
||||
// @access Private (owner)
|
||||
const leaveWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry || entry.userId !== req.user.id) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('You\'re no longer on this waitlist.'); }
|
||||
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'left', offerTokenHash: null } });
|
||||
if (entry.status === 'offered') queueWaitlist(entry.eventId);
|
||||
res.json({ message: 'You\'ve left the waitlist.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc What a claim link offers
|
||||
// @route GET /api/waitlist/claim/:token
|
||||
// @access Public (the token is the credential)
|
||||
const claimInfo = async (req, res) => {
|
||||
try {
|
||||
const entry = await findOfferByToken(req.params.token);
|
||||
if (!entry) { res.status(404); throw new Error('This waitlist link is not valid or has already been used.'); }
|
||||
const expired = entry.status !== 'offered' || new Date(entry.offerExpiresAt) < new Date();
|
||||
res.json({
|
||||
status: expired ? 'expired' : 'offered',
|
||||
eventId: entry.eventId,
|
||||
eventTitle: entry.event.title,
|
||||
eventStart: entry.event.startDate,
|
||||
eventOptionId: entry.eventOptionId,
|
||||
optionName: entry.eventOption.name,
|
||||
variantId: entry.variantId,
|
||||
variantName: entry.variant?.name || null,
|
||||
quantity: entry.offeredQuantity,
|
||||
requested: entry.quantity,
|
||||
expiresAt: entry.offerExpiresAt,
|
||||
name: entry.user?.name,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The waitlist for an event
|
||||
// @route GET /api/waitlist/events/:eventId
|
||||
// @access Private/Staff
|
||||
const eventWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entries = await prisma.waitlistEntry.findMany({
|
||||
where: { eventId: req.params.eventId, ...(req.query.all === 'true' ? {} : { status: { in: [...ACTIVE, 'claimed', 'expired'] } }) },
|
||||
include: { ...ENTRY_INCLUDE, user: { select: { id: true, name: true, email: true, phoneNumber: true } } },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Offer a place to someone on the waitlist now (even without free stock)
|
||||
// @route POST /api/waitlist/:id/offer
|
||||
// @access Private/Supervisor
|
||||
const offerManually = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!['waiting', 'expired'].includes(entry.status)) { res.status(400); throw new Error(`This person is ${entry.status === 'offered' ? 'already holding an offer' : `no longer waiting (${entry.status})`}.`); }
|
||||
const qty = Math.max(1, Math.min(entry.quantity, parseInt(req.body?.quantity, 10) || entry.quantity));
|
||||
const updated = await makeOffer(entry.id, qty);
|
||||
res.json({ ...updated, message: `Offered ${qty} place(s); the claim link has been sent.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Move someone up or down the queue
|
||||
// @route POST /api/waitlist/:id/move { direction: 'up' | 'down' }
|
||||
// @access Private/Supervisor
|
||||
const moveEntry = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
const up = req.body?.direction === 'up';
|
||||
const neighbour = await prisma.waitlistEntry.findFirst({
|
||||
where: { eventId: entry.eventId, status: 'waiting', position: up ? { lt: entry.position } : { gt: entry.position } },
|
||||
orderBy: { position: up ? 'desc' : 'asc' },
|
||||
});
|
||||
if (!neighbour) return res.json({ message: 'Already at the ' + (up ? 'front.' : 'back.') });
|
||||
await prisma.$transaction([
|
||||
prisma.waitlistEntry.update({ where: { id: entry.id }, data: { position: neighbour.position } }),
|
||||
prisma.waitlistEntry.update({ where: { id: neighbour.id }, data: { position: entry.position } }),
|
||||
]);
|
||||
res.json({ message: 'Moved.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Take someone off the waitlist (an open offer is passed on)
|
||||
// @route POST /api/waitlist/:id/remove
|
||||
// @access Private/Supervisor
|
||||
const removeEntry = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('This person is not on the waitlist any more.'); }
|
||||
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'removed', offerTokenHash: null } });
|
||||
if (entry.status === 'offered') queueWaitlist(entry.eventId);
|
||||
res.json({ message: 'Removed from the waitlist.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry };
|
||||
@@ -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: {
|
||||
|
||||
@@ -176,6 +176,11 @@ app.use('/api', costRoutes);
|
||||
app.use('/api/cashups', cashupRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/coupons', require('./routes/couponRoutes'));
|
||||
app.use('/api/consent', require('./routes/consentRoutes'));
|
||||
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
|
||||
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
|
||||
app.use('/api/allocations', require('./routes/allocationRoutes'));
|
||||
app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
|
||||
app.use('/api/backups', backupRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
@@ -610,6 +615,10 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/registrations/event/:eventId', auth:'staff+', desc:'Get all registrations for a specific event',
|
||||
pathParams:{ ':eventId':'Event UUID' },
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', user:{ id:'user-uuid-...', name:'Jane Doe', email:'jane@example.com', phoneNumber:'+27821234567' }, status:'paid' }]}]},
|
||||
{ method:'PUT', path:'/api/registrations/:id/attendees', auth:'user+', desc:'Replace the named attendees on a registration (group tickets) and choose ticket delivery (payer | each). Owner or staff. Removing an attendee whose ticket was scanned is refused.',
|
||||
request:{ body:{ attendees:[{ id:'att-uuid-...', eventOptionId:'opt-uuid-...', name:'Sam Smith', email:'sam@example.com' },{ eventOptionId:'opt-uuid-...', name:'Alex Smith' }], ticketDelivery:'each' }},
|
||||
responses:[{ status:200, desc:'Saved', body:{ id:'reg-uuid-...', ticketDelivery:'each', attendees:[{ id:'att-uuid-...', name:'Sam Smith', ticket:{ id:'tkt-uuid-...', isUsed:false }}] }}]},
|
||||
{ method:'POST', path:'/api/registrations/:id/attendees/:attendeeId/send-form-link', auth:'user+', desc:'Email/WhatsApp a named attendee the link to fill in their own attendee form' },
|
||||
{ method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.',
|
||||
request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }},
|
||||
responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]},
|
||||
@@ -628,6 +637,72 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
]},
|
||||
|
||||
{ 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:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
|
||||
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
|
||||
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
|
||||
{ method:'GET', path:'/api/tickets/:id/transfers', auth:'payer, holder or staff+', desc:'Transfer history of a ticket' },
|
||||
]},
|
||||
{ title: 'Offline scanning', base: '/api/scan-locks', endpoints: [
|
||||
{ method:'GET', path:'/api/scan-locks', auth:'staff+', desc:'Active offline locks for an event (?eventId=)' },
|
||||
{ method:'POST', path:'/api/scan-locks', auth:'staff+', desc:'Lock a section (or the whole event) to this device for offline scanning',
|
||||
request:{ body:{ eventId:'ev-uuid-...', sectionId:'sec-uuid-...', deviceId:'device-uuid', deviceName:'Chrome on Android' }},
|
||||
responses:[{ status:201, desc:'Locked', body:{ id:'lock-uuid-...', sectionName:'Main hall' }}, { status:409, desc:'Held elsewhere', body:{ message:'"Main hall" is offline-locked by Ann on Chrome on Android.' }}]},
|
||||
{ method:'GET', path:'/api/scan-locks/:id/snapshot', auth:'staff+', desc:'Tickets valid in the lock scope, for the device to keep offline (lock holder, ?deviceId=)' },
|
||||
{ method:'POST', path:'/api/scan-locks/:id/sync', auth:'staff+', desc:'Upload offline scans (idempotent by clientScanId; capped at what is left on each ticket)',
|
||||
request:{ body:{ deviceId:'device-uuid', scans:[{ clientScanId:'scan-uuid', qrCode:'qr-uuid', qty:1, scannedAt:'2025-07-10T08:15:00.000Z' }] }},
|
||||
responses:[{ status:200, desc:'Synced', body:{ accepted:1, duplicates:0, conflicts:[] }}]},
|
||||
{ method:'DELETE', path:'/api/scan-locks/:id', auth:'staff+', desc:'End offline mode (lock holder)' },
|
||||
{ method:'POST', path:'/api/scan-locks/:id/force-release', auth:'supervisor+', desc:'Release a lost device\'s lock' },
|
||||
{ method:'GET', path:'/api/scan-locks/reports', auth:'supervisor+', desc:'Offline sync reports for an event' },
|
||||
{ method:'POST', path:'/api/scan-locks/reports/:id/review', auth:'supervisor+', desc:'Mark a sync report reviewed' },
|
||||
]},
|
||||
{ title: 'Waitlist', base: '/api/waitlist', endpoints: [
|
||||
{ method:'POST', path:'/api/waitlist', auth:'public', desc:'Join the waitlist for a sold-out line (login needed when the event requires it)',
|
||||
request:{ body:{ eventId:'ev-uuid-...', eventOptionId:'opt-uuid-...', quantity:2 }},
|
||||
responses:[{ status:201, desc:'Joined', body:{ id:'wl-uuid-...', status:'waiting', ahead:3 }}, { status:400, desc:'Places available', body:{ message:'There are places available — you can register now.' }}]},
|
||||
{ method:'GET', path:'/api/waitlist/mine', auth:'user+', desc:'My active waitlist entries' },
|
||||
{ method:'DELETE', path:'/api/waitlist/:id', auth:'user+', desc:'Leave the waitlist' },
|
||||
{ method:'GET', path:'/api/waitlist/claim/:token', auth:'public', desc:'What a claim link offers (rate-limited)' },
|
||||
{ method:'GET', path:'/api/waitlist/events/:eventId', auth:'staff+', desc:'The queue for an event' },
|
||||
{ method:'POST', path:'/api/waitlist/:id/offer', auth:'supervisor+', desc:'Offer places now, even over the stock limit' },
|
||||
{ method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} },
|
||||
{ method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' },
|
||||
]},
|
||||
{ title: 'Parental consent', base: '/api/consent', endpoints: [
|
||||
{ method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)',
|
||||
responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]},
|
||||
{ method:'POST', path:'/api/consent/:token', auth:'public', desc:'Give or decline consent. Records name, time, IP, device and the consent text version; medical/emergency details are stored encrypted.',
|
||||
request:{ body:{ decision:'give', signedName:'Pat Smith', isGuardian:true, medical:{ allergies:'Peanuts' }, emergency:{ name:'Gran', phone:'0821234567' }, photoConsent:true }},
|
||||
responses:[{ status:200, desc:'Answered', body:{ status:'given' }}, { status:409, desc:'Already answered', body:{ message:'Consent has already been given.' }}]},
|
||||
{ method:'GET', path:'/api/consent/events/:eventId', auth:'staff+', desc:'Consent status for every attendee on an event' },
|
||||
{ method:'POST', path:'/api/consent/requests/:id/resend', auth:'user+', desc:'Re-send a pending request with a fresh link (registrant or staff)' },
|
||||
{ method:'POST', path:'/api/consent/events/:eventId/resend-pending', auth:'supervisor+', desc:'Re-send every pending request on an event' },
|
||||
{ method:'POST', path:'/api/consent/events/:eventId/rerequest', auth:'supervisor+', desc:'Ask again where consent was given on an older version of the text, or never requested' },
|
||||
{ method:'GET', path:'/api/consent/events/:eventId/medical', auth:'supervisor+', desc:'Decrypted medical & emergency list; every view is written to the admin audit log' },
|
||||
]},
|
||||
{ title: 'Coupons', base: '/api/coupons', endpoints: [
|
||||
{ method:'POST', path:'/api/coupons/validate', auth:'public', desc:'Check a code against a basket before registering; returns the discount. Login needed for restricted codes; staff can pass forUserId (or null for a walk-in).',
|
||||
request:{ body:{ code:'STAFF50', eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
|
||||
@@ -1291,6 +1366,59 @@ app.listen(PORT, () => {
|
||||
console.warn('[daily summaries] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Waitlist job — expire unclaimed offers and offer any free places to the next people.
|
||||
try {
|
||||
const enabled = String(process.env.WAITLIST_JOB_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runWaitlistJob } = require('./utils/waitlist');
|
||||
const everyMs = parseInt(process.env.WAITLIST_JOB_INTERVAL_MS || String(10 * 60 * 1000), 10);
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await runWaitlistJob();
|
||||
if (r.expired || r.offers) console.log(`[waitlist] ${r.expired} event(s) with expired offers, ${r.offers} new offer(s).`);
|
||||
} catch (e) {
|
||||
console.warn('[waitlist] Job failed:', e?.message || e);
|
||||
}
|
||||
}, everyMs);
|
||||
console.log(`[waitlist] Job every ${Math.round(everyMs / 60000)} min.`);
|
||||
} else {
|
||||
console.log('[waitlist] Job disabled by env WAITLIST_JOB_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[waitlist] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily parental-consent job — reminders for unanswered requests, and purging
|
||||
// medical/emergency details a set number of days after each event.
|
||||
try {
|
||||
const enabled = String(process.env.CONSENT_JOBS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { sendConsentReminders, purgeConsentMedicalData } = require('./utils/consent');
|
||||
const runConsentJobs = async () => {
|
||||
try {
|
||||
const reminded = await sendConsentReminders(new Date());
|
||||
const purged = await purgeConsentMedicalData(new Date());
|
||||
if (reminded || purged) console.log(`[consent] Sent ${reminded} reminder(s), purged medical details on ${purged} request(s).`);
|
||||
} catch (e) {
|
||||
console.warn('[consent] Daily job failed:', e?.message || e);
|
||||
}
|
||||
};
|
||||
const scheduleConsent = () => {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(9, 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
setTimeout(async () => { await runConsentJobs(); scheduleConsent(); }, next.getTime() - now.getTime());
|
||||
};
|
||||
scheduleConsent();
|
||||
console.log('[consent] Scheduler initialized (09:00 local time).');
|
||||
} else {
|
||||
console.log('[consent] Scheduler disabled by env CONSENT_JOBS_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[consent] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily temp-file cleanup — removes files older than 7 days from /temp
|
||||
try {
|
||||
const { cleanupTempFiles } = require('./utils/cleanupTemp');
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,20 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
// The token in the link is the parent's only credential, so keep guessing slow.
|
||||
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.get('/events/:eventId', protect, staff, listEventConsent);
|
||||
router.post('/events/:eventId/resend-pending', protect, supervisor, resendPending);
|
||||
router.post('/events/:eventId/rerequest', protect, supervisor, rerequestConsent);
|
||||
router.get('/events/:eventId/medical', protect, supervisor, medicalList);
|
||||
router.post('/requests/:id/resend', protect, resendRequest);
|
||||
|
||||
// Public consent page
|
||||
router.get('/:token', consentLimiter, getConsent);
|
||||
router.post('/:token', consentLimiter, answerConsent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
getFormDraft, saveFormDraft,
|
||||
sendInvoice,
|
||||
applyCoupon, removeCoupon,
|
||||
updateAttendees, sendAttendeeFormLink,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -25,6 +26,8 @@ router.delete('/:id', protect, cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, sendInvoice);
|
||||
router.post('/:id/coupon', protect, applyCoupon);
|
||||
router.delete('/:id/coupon', protect, staff, removeCoupon);
|
||||
router.put('/:id/attendees', protect, updateAttendees);
|
||||
router.post('/:id/attendees/:attendeeId/send-form-link', protect, sendAttendeeFormLink);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, getRegistrationById);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -13,7 +13,9 @@ const {
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
getScanStats,
|
||||
transferTicketHandler,
|
||||
getTicketTransfers
|
||||
} = require('../controllers/ticketController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -21,6 +23,8 @@ const { protect, admin, supervisor, staff } = require('../middleware/authMiddlew
|
||||
router.get('/mytickets', protect, getUserTickets);
|
||||
router.get('/:id', protect, getTicketById);
|
||||
router.post('/email', protect, emailTickets);
|
||||
router.post('/:id/transfer', protect, transferTicketHandler);
|
||||
router.get('/:id/transfers', protect, getTicketTransfers);
|
||||
|
||||
// Staff routes
|
||||
router.post('/send-to', protect, staff, sendTicketsTo);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
|
||||
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// Claim links are credentials, so keep guessing slow.
|
||||
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.post('/', optionalAuth, joinWaitlist);
|
||||
router.get('/mine', protect, myWaitlist);
|
||||
router.get('/claim/:token', claimLimiter, claimInfo);
|
||||
router.get('/events/:eventId', protect, staff, eventWaitlist);
|
||||
router.post('/:id/offer', protect, supervisor, offerManually);
|
||||
router.post('/:id/move', protect, supervisor, moveEntry);
|
||||
router.post('/:id/remove', protect, supervisor, removeEntry);
|
||||
router.delete('/:id', protect, leaveWaitlist);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Finding an existing account from an email and/or phone number.
|
||||
*
|
||||
* Email and phone are looked up separately rather than with one OR query, so an email
|
||||
* belonging to one account and a phone belonging to another can never be silently
|
||||
* collapsed into whichever matches first — that would let a registration hijack or
|
||||
* corrupt someone else's account. Phones are tried in both 27xxx and 0xxx forms.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
function hasValidEmail(email) {
|
||||
return !!(email && typeof email === 'string' && email.includes('@'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ phone: string, emailUser: object|null, phoneUser: object|null,
|
||||
* conflict: boolean, user: object|null }>}
|
||||
* `user` is the single matched account (null when none, or when email and phone point
|
||||
* at two different accounts — `conflict` is then true).
|
||||
*/
|
||||
async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
|
||||
const { normalizeZAPhone } = require('./whatsapp');
|
||||
const phone = normalizeZAPhone(rawPhone) || String(rawPhone || '').replace(/\D/g, '');
|
||||
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
|
||||
|
||||
const [emailUser, phoneUser] = await Promise.all([
|
||||
hasValidEmail(email) ? db.user.findUnique({ where: { email: String(email).trim() } }) : null,
|
||||
phone ? db.user.findFirst({ where: { OR: [{ phoneNumber: phone }, ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : [])] } }) : null,
|
||||
]);
|
||||
|
||||
const conflict = !!(emailUser && phoneUser && emailUser.id !== phoneUser.id);
|
||||
return { phone, emailUser, phoneUser, conflict, user: conflict ? null : (emailUser || phoneUser || null) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The account for these contact details, or a new inactive one (same as guest
|
||||
* registration: a placeholder email for phone-only people, a random password, and an
|
||||
* activation link sent by the caller when `created` is true).
|
||||
*/
|
||||
async function findOrCreateAccount({ name, email, phone }, db = prisma) {
|
||||
const match = await findAccountByContact({ email, phone }, db);
|
||||
if (match.conflict) {
|
||||
const err = new Error('That email address and phone number belong to two different accounts. Use just one of them.');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (match.user) return { user: match.user, created: false };
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { hashPassword } = require('../config/auth');
|
||||
const validEmail = hasValidEmail(email);
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
email: validEmail ? String(email).trim() : `guest+${uuidv4().slice(0, 8)}@guest.local`,
|
||||
password: await hashPassword(uuidv4()),
|
||||
phoneNumber: match.phone || null,
|
||||
isActive: false,
|
||||
notificationPreference: validEmail && match.phone ? 'both' : validEmail ? 'email' : 'whatsapp',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { user, created: true };
|
||||
}
|
||||
|
||||
module.exports = { findAccountByContact, findOrCreateAccount, hasValidEmail };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Group tickets — named attendees on a registration.
|
||||
*
|
||||
* Rules:
|
||||
* - Only main-ticket places can be named, and only on events with
|
||||
* allowNamedAttendees. Each named attendee gets their own quantity-1 ticket; any
|
||||
* places left unnamed share one quantity ticket, as before.
|
||||
* - The payer stays the registration owner (billing, invoices, payments). An attendee
|
||||
* whose email/phone matches an existing account is linked to it, so the ticket also
|
||||
* shows in that person's dashboard — unless email and phone point at two different
|
||||
* accounts, in which case nothing is linked.
|
||||
* - Registration.ticketDelivery = 'each' sends each attendee their own ticket by
|
||||
* email/WhatsApp; attendees with no contact details fall back to the payer.
|
||||
* - Youth events (requiresParentalConsent): every main place must be named with a date
|
||||
* of birth, and anyone under 18 on the start date needs a parent/guardian name, email
|
||||
* and phone (see utils/consent.js).
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { findAccountByContact } = require('./accountMatch');
|
||||
const { isMinor } = require('./consent');
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
const lineKey = (eventOptionId, variantId) => `${eventOptionId}::${variantId || ''}`;
|
||||
|
||||
function attendeeError(message) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = 400;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks attendees against the event and the number of places on each line. Pure.
|
||||
* Returns the cleaned list (trimmed strings, nulls for blanks).
|
||||
*
|
||||
* @param {object} event - with allowNamedAttendees and eventOptions[{ id, isMainTicket, name }]
|
||||
* @param {object} placesByKey - total places each line will have, keyed by lineKey()
|
||||
* @param {Array} attendees - [{ eventOptionId, variantId?, name, email?, phone? }]
|
||||
* @param {object} [namedByKey] - places on those lines already named (and being kept)
|
||||
*/
|
||||
function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
|
||||
const consent = !!event.requiresParentalConsent;
|
||||
if ((!attendees || attendees.length === 0) && !consent) return [];
|
||||
if (!event.allowNamedAttendees && !consent) throw attendeeError('This event doesn\'t take attendee names.');
|
||||
if (!Array.isArray(attendees || [])) throw attendeeError('attendees must be a list');
|
||||
|
||||
const countByKey = {};
|
||||
const cleaned = (attendees || []).map((a, i) => {
|
||||
const option = (event.eventOptions || []).find(o => o.id === a?.eventOptionId);
|
||||
if (!option) throw attendeeError(`Attendee ${i + 1}: unknown ticket option.`);
|
||||
if (!option.isMainTicket) throw attendeeError(`Attendee ${i + 1}: names can only be given for "${option.name}" main tickets.`);
|
||||
const name = String(a.name || '').trim();
|
||||
if (!name) throw attendeeError(`Attendee ${i + 1}: enter a name.`);
|
||||
const email = String(a.email || '').trim() || null;
|
||||
if (email && !EMAIL_RE.test(email)) throw attendeeError(`Attendee ${i + 1}: "${email}" isn't a valid email address.`);
|
||||
const phone = String(a.phone || '').trim() || null;
|
||||
const key = lineKey(a.eventOptionId, a.variantId);
|
||||
countByKey[key] = (countByKey[key] || 0) + 1;
|
||||
if (countByKey[key] + (namedByKey[key] || 0) > (placesByKey[key] || 0)) {
|
||||
throw attendeeError(`More names than "${option.name}" tickets.`);
|
||||
}
|
||||
|
||||
// Date of birth (always kept if given) and, for youth events, parent details.
|
||||
let dateOfBirth = null;
|
||||
if (a.dateOfBirth) {
|
||||
dateOfBirth = new Date(a.dateOfBirth);
|
||||
if (isNaN(dateOfBirth.getTime()) || dateOfBirth > new Date()) throw attendeeError(`${name}: enter a valid date of birth.`);
|
||||
} else if (consent) {
|
||||
throw attendeeError(`${name}: enter a date of birth.`);
|
||||
}
|
||||
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;
|
||||
if (consent && isMinor(dateOfBirth, event.startDate)) {
|
||||
if (!parentName || !parentEmail || !parentPhone) {
|
||||
throw attendeeError(`${name} is under 18, so a parent or guardian's name, email and phone are needed for consent.`);
|
||||
}
|
||||
if (!EMAIL_RE.test(parentEmail)) throw attendeeError(`${name}: "${parentEmail}" isn't a valid parent email address.`);
|
||||
}
|
||||
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone };
|
||||
});
|
||||
|
||||
// Youth events need every main place named (with a date of birth).
|
||||
if (consent) {
|
||||
for (const o of event.eventOptions || []) {
|
||||
if (!o.isMainTicket) continue;
|
||||
for (const [key, places] of Object.entries(placesByKey)) {
|
||||
if (!key.startsWith(`${o.id}::`)) continue;
|
||||
if ((countByKey[key] || 0) + (namedByKey[key] || 0) < places) {
|
||||
throw attendeeError(`This is a youth event — add a name and date of birth for every "${o.name}" ticket.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out the tickets one registration line should have, given its named attendees.
|
||||
* Pure — returns operations for the caller to run.
|
||||
*
|
||||
* - Every attendee gets a quantity-1 ticket (holderUserId follows the attendee's account).
|
||||
* - Unnamed places share one quantity ticket; a scanned one is never deleted or shrunk
|
||||
* below what's been redeemed.
|
||||
* - Tickets for attendees that no longer exist are removed unless already scanned.
|
||||
*
|
||||
* @param {object} line - { quantity }
|
||||
* @param {Array} attendees - [{ id, userId }]
|
||||
* @param {Array} tickets - existing [{ id, attendeeId, quantity, holderUserId, usages: [] }]
|
||||
* @returns {{ create: Array<{attendeeId, holderUserId, quantity}>, update: Array<{id, data}>, remove: string[] }}
|
||||
*/
|
||||
function planLineTickets(line, attendees, tickets) {
|
||||
const scanned = (t) => (t.usages || []).length > 0;
|
||||
const redeemed = (t) => (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 0), 0);
|
||||
const ops = { create: [], update: [], remove: [] };
|
||||
const attendeeIds = new Set(attendees.map(a => a.id));
|
||||
|
||||
const byAttendee = new Map();
|
||||
const orphanNamed = [];
|
||||
const shared = [];
|
||||
for (const t of tickets) {
|
||||
if (t.attendeeId && attendeeIds.has(t.attendeeId)) byAttendee.set(t.attendeeId, t);
|
||||
else if (t.attendeeId) orphanNamed.push(t);
|
||||
else shared.push(t);
|
||||
}
|
||||
|
||||
// Tickets whose attendee was removed: drop unless already used (then keep, as shared).
|
||||
let keptScannedOrphans = 0;
|
||||
for (const t of orphanNamed) {
|
||||
if (scanned(t)) keptScannedOrphans += t.quantity || 1;
|
||||
else ops.remove.push(t.id);
|
||||
}
|
||||
|
||||
for (const a of attendees) {
|
||||
const t = byAttendee.get(a.id);
|
||||
if (!t) {
|
||||
ops.create.push({ attendeeId: a.id, holderUserId: a.userId || null, quantity: 1 });
|
||||
} else if ((t.holderUserId || null) !== (a.userId || null) || (t.quantity || 1) !== 1) {
|
||||
ops.update.push({ id: t.id, data: { holderUserId: a.userId || null, ...(scanned(t) ? {} : { quantity: 1 }) } });
|
||||
}
|
||||
}
|
||||
|
||||
// Unnamed places share one ticket.
|
||||
const remainder = Math.max(0, (line.quantity || 0) - attendees.length - keptScannedOrphans);
|
||||
const sorted = [...shared].sort((a, b) => (scanned(b) ? 1 : 0) - (scanned(a) ? 1 : 0));
|
||||
const [primary, ...extras] = sorted;
|
||||
if (primary) {
|
||||
const floor = scanned(primary) ? redeemed(primary) : 0;
|
||||
if (remainder === 0 && !scanned(primary)) ops.remove.push(primary.id);
|
||||
else {
|
||||
const qty = Math.max(remainder, floor);
|
||||
if (qty !== primary.quantity) ops.update.push({ id: primary.id, data: { quantity: qty } });
|
||||
}
|
||||
} else if (remainder > 0) {
|
||||
ops.create.push({ attendeeId: null, holderUserId: null, quantity: remainder });
|
||||
}
|
||||
for (const t of extras) if (!scanned(t)) ops.remove.push(t.id);
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves new attendees on a registration, linking each to its registration line and to
|
||||
* a matching account when there is exactly one.
|
||||
*/
|
||||
async function createAttendees(registrationId, attendees, db = prisma) {
|
||||
if (!attendees || attendees.length === 0) return [];
|
||||
const lines = await db.registrationOption.findMany({ where: { registrationId } });
|
||||
const existing = await db.attendee.findMany({ where: { registrationId }, select: { registrationOptionId: true } });
|
||||
const orderByLine = {};
|
||||
existing.forEach(a => { orderByLine[a.registrationOptionId] = (orderByLine[a.registrationOptionId] || 0) + 1; });
|
||||
|
||||
const created = [];
|
||||
for (const a of attendees) {
|
||||
const line = lines.find(l => l.eventOptionId === a.eventOptionId && (l.variantId || null) === (a.variantId || null));
|
||||
const match = (a.email || a.phone) ? await findAccountByContact({ email: a.email, phone: a.phone }, db) : { user: null };
|
||||
const order = orderByLine[line?.id] || 0;
|
||||
orderByLine[line?.id] = order + 1;
|
||||
created.push(await db.attendee.create({
|
||||
data: {
|
||||
registrationId,
|
||||
registrationOptionId: line?.id || null,
|
||||
eventOptionId: a.eventOptionId,
|
||||
variantId: a.variantId || null,
|
||||
name: a.name,
|
||||
email: a.email,
|
||||
phone: a.phone,
|
||||
dateOfBirth: a.dateOfBirth || null,
|
||||
gender: a.gender || null,
|
||||
parentName: a.parentName || null,
|
||||
parentEmail: a.parentEmail || null,
|
||||
parentPhone: a.parentPhone || null,
|
||||
userId: match.user?.id || null,
|
||||
order,
|
||||
},
|
||||
}));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Named places already on a registration, per line key — for validating additions. */
|
||||
async function namedPlacesByKey(registrationId, db = prisma) {
|
||||
const rows = await db.attendee.findMany({ where: { registrationId }, select: { eventOptionId: true, variantId: true } });
|
||||
const out = {};
|
||||
rows.forEach(r => { const k = lineKey(r.eventOptionId, r.variantId); out[k] = (out[k] || 0) + 1; });
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { lineKey, validateAttendees, planLineTickets, createAttendees, namedPlacesByKey, attendeeError };
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Parental consent for youth events.
|
||||
*
|
||||
* Rules:
|
||||
* - On an event with requiresParentalConsent, every attendee gives a date of birth.
|
||||
* Anyone under 18 on the event's start date needs a parent/guardian (name, email,
|
||||
* phone), who is sent a consent request by email AND WhatsApp — regardless of anyone's
|
||||
* notification preference.
|
||||
* - A request's link token is random; only its HMAC is stored. Links expire when the
|
||||
* event ends and can be re-sent (which issues a new token).
|
||||
* - The parent's answer records the time, IP, device, typed name, and the exact consent
|
||||
* text and version they saw. Medical/emergency details are stored encrypted and purged
|
||||
* a set number of days after the event (setting consent_medical_retention_days).
|
||||
* - Tickets are issued as normal but can't be checked in until consent is given — the
|
||||
* latest request for the attendee decides. On a consent event, a ticket with no named
|
||||
* attendee is treated as missing consent. Supervisors can override (audited).
|
||||
* - Reminders go 2 days after the request, then every 3 days, until answered or the event.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const FIRST_REMINDER_AFTER = 2 * DAY;
|
||||
const REMINDER_EVERY = 3 * DAY;
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
function consentError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Whole years old on `onDate`. */
|
||||
function ageOn(dateOfBirth, onDate) {
|
||||
const dob = new Date(dateOfBirth);
|
||||
const on = new Date(onDate);
|
||||
let age = on.getUTCFullYear() - dob.getUTCFullYear();
|
||||
const beforeBirthday = on.getUTCMonth() < dob.getUTCMonth()
|
||||
|| (on.getUTCMonth() === dob.getUTCMonth() && on.getUTCDate() < dob.getUTCDate());
|
||||
if (beforeBirthday) age -= 1;
|
||||
return age;
|
||||
}
|
||||
|
||||
/** Under 18 on the event's start date. */
|
||||
function isMinor(dateOfBirth, eventStart) {
|
||||
if (!dateOfBirth) return false;
|
||||
return ageOn(dateOfBirth, eventStart) < 18;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consent state for one attendee: 'not_required' | 'missing' | 'pending' | 'given' | 'declined'.
|
||||
* `latest` is the attendee's most recent ConsentRequest (or null).
|
||||
*/
|
||||
function consentStatusFor(attendee, event, latest) {
|
||||
if (!event?.requiresParentalConsent) return 'not_required';
|
||||
if (!attendee) return 'missing'; // unnamed place on a consent event
|
||||
if (!isMinor(attendee.dateOfBirth, event.startDate)) return 'not_required';
|
||||
if (!latest) return 'missing';
|
||||
return latest.status;
|
||||
}
|
||||
|
||||
/** Whether a pending request is due a reminder now. */
|
||||
function reminderDue(request, now = new Date()) {
|
||||
if (!request || request.status !== 'pending') return false;
|
||||
const t = new Date(now).getTime();
|
||||
if (t >= new Date(request.expiresAt).getTime()) return false;
|
||||
const gap = request.remindersSent === 0 ? FIRST_REMINDER_AFTER : REMINDER_EVERY;
|
||||
return t - new Date(request.lastSentAt).getTime() >= gap;
|
||||
}
|
||||
|
||||
const str = (v, max = 500) => String(v ?? '').trim().slice(0, max);
|
||||
|
||||
/**
|
||||
* Checks a parent's answer on the consent page. Pure. Returns the cleaned answer.
|
||||
* @param {object} body - { decision: 'give'|'decline', signedName, isGuardian, medical, emergency, photoConsent }
|
||||
* @param {object} event - with consentCollectMedical / Emergency / Photo
|
||||
*/
|
||||
function validateConsentAnswer(body, event) {
|
||||
const decision = body?.decision;
|
||||
if (decision !== 'give' && decision !== 'decline') throw consentError('Choose to give or decline consent.');
|
||||
const signedName = str(body.signedName, 200);
|
||||
if (signedName.length < 2) throw consentError('Type your full name to sign.');
|
||||
if (body.isGuardian !== true) throw consentError('Confirm that you are the parent or legal guardian.');
|
||||
|
||||
const out = { decision, signedName, medical: null, emergency: null, photoConsent: null };
|
||||
if (decision === 'decline') return out;
|
||||
|
||||
if (event.consentCollectMedical) {
|
||||
const m = body.medical || {};
|
||||
out.medical = {
|
||||
allergies: str(m.allergies), medication: str(m.medication),
|
||||
conditions: str(m.conditions), medicalAid: str(m.medicalAid, 200),
|
||||
};
|
||||
}
|
||||
if (event.consentCollectEmergency) {
|
||||
const em = body.emergency || {};
|
||||
out.emergency = { name: str(em.name, 200), phone: str(em.phone, 40), relationship: str(em.relationship, 100) };
|
||||
if (!out.emergency.name || !out.emergency.phone) throw consentError('Add an emergency contact name and phone number.');
|
||||
}
|
||||
if (event.consentCollectPhoto) {
|
||||
if (body.photoConsent !== true && body.photoConsent !== false) throw consentError('Answer the photo consent question.');
|
||||
out.photoConsent = body.photoConsent;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Tokens ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`consent:${token}`).digest('hex');
|
||||
const newToken = () => crypto.randomBytes(24).toString('hex');
|
||||
|
||||
function consentLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/consent/${token}`;
|
||||
}
|
||||
|
||||
// ─── Encryption of medical/emergency details ──────────────────────────────────
|
||||
|
||||
function sealJson(value) {
|
||||
if (!value) return null;
|
||||
const { encrypt } = require('./encryption');
|
||||
return encrypt(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function openJson(value) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const { decrypt } = require('./encryption');
|
||||
return JSON.parse(decrypt(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Latest request per attendee id. */
|
||||
async function latestRequestsFor(attendeeIds, db = prisma) {
|
||||
if (!attendeeIds.length) return new Map();
|
||||
const rows = await db.consentRequest.findMany({ where: { attendeeId: { in: attendeeIds } }, orderBy: { createdAt: 'desc' } });
|
||||
const out = new Map();
|
||||
for (const r of rows) if (!out.has(r.attendeeId)) out.set(r.attendeeId, r);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function sendConsentMessage(request, token, { reminder = false } = {}) {
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||||
const { waTextAny } = require('./notify');
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
|
||||
const event = request.event || await prisma.event.findUnique({ where: { id: request.eventId } });
|
||||
const attendee = request.attendee || await prisma.attendee.findUnique({ where: { id: request.attendeeId } });
|
||||
const link = consentLink(token);
|
||||
const when = event?.startDate ? new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
|
||||
const subject = `${reminder ? 'Reminder: ' : ''}Consent needed for ${attendee.name} — ${event.title}`;
|
||||
const intro = `${attendee.name} has been registered for <strong>${event.title}</strong>${when ? ` on <strong>${when}</strong>` : ''}. As their parent or guardian, please review the details and give (or decline) consent.`;
|
||||
const sent = [];
|
||||
|
||||
if (request.parentEmail && request.parentEmail.includes('@')) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">${reminder ? 'A reminder: ' : ''}Parental consent needed</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.parentName || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${intro}</p>
|
||||
${ctaButton('Review and give consent', link)}
|
||||
${fallbackLink(link)}
|
||||
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">${attendee.name} can't be checked in at the event until consent is given.</p>
|
||||
`, { preheader: `Consent needed for ${attendee.name}` });
|
||||
try {
|
||||
await sendMail({ to: request.parentEmail, subject, html, text: `Hi ${request.parentName || 'there'},\n\n${intro.replace(/<[^>]+>/g, '')}\n\nReview and give consent: ${link}\n\n${orgName}` });
|
||||
sent.push('email');
|
||||
} catch (e) {
|
||||
console.warn('[consent] email failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
if (request.parentPhone && isValidZAPhone(request.parentPhone)) {
|
||||
await waTextAny({ name: request.parentName, phoneNumber: request.parentPhone },
|
||||
`${reminder ? '⏰ Reminder: ' : ''}Hi ${request.parentName || 'there'}, ${attendee.name} has been registered for *${event.title}*${when ? ` on ${when}` : ''}. Please give (or decline) parental consent here: ${link}`);
|
||||
sent.push('WhatsApp');
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates (and sends) a consent request for an attendee. Earlier requests stay as the
|
||||
* record of what was agreed before; this one now decides check-in.
|
||||
*/
|
||||
async function createConsentRequest(attendee, event) {
|
||||
const token = newToken();
|
||||
const request = await prisma.consentRequest.create({
|
||||
data: {
|
||||
attendeeId: attendee.id,
|
||||
eventId: event.id,
|
||||
tokenHash: hashToken(token),
|
||||
textVersion: event.consentTextVersion || 1,
|
||||
consentText: event.consentText || '',
|
||||
parentName: attendee.parentName,
|
||||
parentEmail: attendee.parentEmail,
|
||||
parentPhone: attendee.parentPhone,
|
||||
expiresAt: event.endDate,
|
||||
},
|
||||
});
|
||||
const sent = await sendConsentMessage({ ...request, event, attendee }, token);
|
||||
return { request, sent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure every under-18 attendee on a registration has a consent request — creates
|
||||
* and sends one for anyone who has none yet, or whose parent details have changed since.
|
||||
*/
|
||||
async function ensureConsentRequests(registrationId) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { event: true, attendees: true } });
|
||||
if (!reg || !reg.event.requiresParentalConsent || reg.status === 'cancelled') return 0;
|
||||
const minors = reg.attendees.filter(a => isMinor(a.dateOfBirth, reg.event.startDate));
|
||||
const latest = await latestRequestsFor(minors.map(a => a.id));
|
||||
let created = 0;
|
||||
for (const a of minors) {
|
||||
const r = latest.get(a.id);
|
||||
const parentChanged = r && (r.parentEmail !== a.parentEmail || r.parentPhone !== a.parentPhone);
|
||||
if (!r || (parentChanged && r.status !== 'given')) {
|
||||
await createConsentRequest(a, reg.event);
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Re-sends a request with a fresh link (the old link stops working). */
|
||||
async function resendConsentRequest(requestId) {
|
||||
const request = await prisma.consentRequest.findUnique({ where: { id: requestId }, include: { event: true, attendee: true } });
|
||||
if (!request) throw consentError('Consent request not found', 404);
|
||||
if (request.status !== 'pending') throw consentError(`Consent has already been ${request.status}.`);
|
||||
if (new Date(request.expiresAt) < new Date()) throw consentError('This event has ended.');
|
||||
const token = newToken();
|
||||
await prisma.consentRequest.update({ where: { id: request.id }, data: { tokenHash: hashToken(token), lastSentAt: new Date() } });
|
||||
return sendConsentMessage(request, token, { reminder: true });
|
||||
}
|
||||
|
||||
async function findRequestByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 100) return null;
|
||||
return prisma.consentRequest.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: { event: true, attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consent state for each ticket id — for the scan gate and staff screens.
|
||||
* @returns {Promise<Map<string, string>>} ticketId → consent status
|
||||
*/
|
||||
async function consentStatusForTickets(ticketIds) {
|
||||
const tickets = await prisma.ticket.findMany({ where: { id: { in: ticketIds } }, include: { event: true, attendee: true } });
|
||||
const latest = await latestRequestsFor(tickets.map(t => t.attendeeId).filter(Boolean));
|
||||
const out = new Map();
|
||||
for (const t of tickets) {
|
||||
out.set(t.id, consentStatusFor(t.attendee, t.event, t.attendeeId ? latest.get(t.attendeeId) : null));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Daily job: reminders for unanswered requests. */
|
||||
async function sendConsentReminders(now = new Date()) {
|
||||
const pending = await prisma.consentRequest.findMany({
|
||||
where: { status: 'pending', expiresAt: { gt: now } },
|
||||
include: { event: true, attendee: true },
|
||||
});
|
||||
// Only the latest request per attendee is still live.
|
||||
const latest = await latestRequestsFor([...new Set(pending.map(r => r.attendeeId))]);
|
||||
let sent = 0;
|
||||
for (const r of pending) {
|
||||
if (latest.get(r.attendeeId)?.id !== r.id || !reminderDue(r, now)) continue;
|
||||
if (new Date(r.event.startDate) <= now) continue; // the event has started
|
||||
const token = newToken();
|
||||
await prisma.consentRequest.update({ where: { id: r.id }, data: { tokenHash: hashToken(token), lastSentAt: now, remindersSent: { increment: 1 } } });
|
||||
await sendConsentMessage(r, token, { reminder: true }).catch(e => console.warn('[consent] reminder failed:', e?.message || e));
|
||||
sent += 1;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Daily job: delete medical/emergency details a set number of days after the event. */
|
||||
async function purgeConsentMedicalData(now = new Date()) {
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const days = Math.max(0, parseInt(getSettingSync('consent_medical_retention_days', String(DEFAULT_RETENTION_DAYS)), 10) || DEFAULT_RETENTION_DAYS);
|
||||
const cutoff = new Date(new Date(now).getTime() - days * DAY);
|
||||
const res = await prisma.consentRequest.updateMany({
|
||||
where: { medicalPurgedAt: null, event: { endDate: { lt: cutoff } }, OR: [{ medical: { not: null } }, { emergency: { not: null } }] },
|
||||
data: { medical: null, emergency: null, medicalPurgedAt: now },
|
||||
});
|
||||
return res.count;
|
||||
}
|
||||
|
||||
/** Clears parent, medical and emergency details for a person's registrations (anonymise). */
|
||||
async function clearConsentDataForUser(userId, db = prisma) {
|
||||
const attendees = await db.attendee.findMany({ where: { OR: [{ userId }, { registration: { userId } }] }, select: { id: true } });
|
||||
const ids = attendees.map(a => a.id);
|
||||
if (!ids.length) return 0;
|
||||
await db.attendee.updateMany({ where: { id: { in: ids } }, data: { parentName: null, parentEmail: null, parentPhone: null, dateOfBirth: null, email: null, phone: null } });
|
||||
const res = await db.consentRequest.updateMany({
|
||||
where: { attendeeId: { in: ids } },
|
||||
data: { medical: null, emergency: null, parentName: null, parentEmail: null, parentPhone: null, ip: null, device: null, medicalPurgedAt: new Date() },
|
||||
});
|
||||
return res.count;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
consentError,
|
||||
ageOn,
|
||||
isMinor,
|
||||
consentStatusFor,
|
||||
reminderDue,
|
||||
validateConsentAnswer,
|
||||
hashToken,
|
||||
consentLink,
|
||||
sealJson,
|
||||
openJson,
|
||||
latestRequestsFor,
|
||||
createConsentRequest,
|
||||
ensureConsentRequests,
|
||||
resendConsentRequest,
|
||||
findRequestByToken,
|
||||
consentStatusForTickets,
|
||||
sendConsentReminders,
|
||||
purgeConsentMedicalData,
|
||||
clearConsentDataForUser,
|
||||
};
|
||||
@@ -103,11 +103,31 @@ async function loadTicketFull(ticketId) {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
|
||||
event: true,
|
||||
registrationOption: { include: { eventOption: true, variant: true } },
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
|
||||
attendee: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a ticket's messages go to: a named attendee with contact details when the
|
||||
* registration sends each person their own tickets, otherwise the payer.
|
||||
*/
|
||||
function ticketRecipient(ticket) {
|
||||
const a = ticket.attendee;
|
||||
if (a && (a.email || a.phone) && ticket.registrationOption?.registration?.ticketDelivery === 'each') {
|
||||
const hasEmail = !!(a.email && a.email.includes('@'));
|
||||
return {
|
||||
id: a.userId || null,
|
||||
name: a.name,
|
||||
email: hasEmail ? a.email : null,
|
||||
phoneNumber: a.phone || null,
|
||||
notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp',
|
||||
};
|
||||
}
|
||||
return ticket.user;
|
||||
}
|
||||
|
||||
async function loadPaymentFull(paymentId) {
|
||||
return prisma.payment.findUnique({
|
||||
where: { id: paymentId },
|
||||
@@ -513,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>
|
||||
@@ -521,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/>
|
||||
@@ -812,7 +832,7 @@ function buildRefundEmail(payment) {
|
||||
|
||||
// ─── Daily event summary ──────────────────────────────────────────────────────
|
||||
|
||||
function buildDailySummary(ev, registrations, payments, now) {
|
||||
function buildDailySummary(ev, registrations, payments, now, waitlistCount = 0) {
|
||||
const org = getOrg();
|
||||
const subject = `Daily summary: ${ev.title} — ${new Date(now).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`;
|
||||
|
||||
@@ -897,6 +917,7 @@ function buildDailySummary(ev, registrations, payments, now) {
|
||||
${statsRow('Total registrations', totalRegistrations)}
|
||||
${statsRow('Confirmed paid', paidCount, '#059669')}
|
||||
${statsRow('Awaiting payment', pendingCount, '#d97706')}
|
||||
${ev.waitlistEnabled ? statsRow('On the waitlist', waitlistCount, '#7c3aed') : ''}
|
||||
<td style="text-align:center;padding:16px">
|
||||
<div style="font-size:24px;font-weight:800;color:#2563eb;font-family:${ff}">${fmtAmount(totalRevenue)}</div>
|
||||
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">Total collected</div>
|
||||
@@ -919,7 +940,7 @@ function buildDailySummary(ev, registrations, payments, now) {
|
||||
`Daily Summary — ${ev.title}`,
|
||||
new Date(now).toLocaleDateString(),
|
||||
'',
|
||||
`Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount} | Revenue: ${fmtAmount(totalRevenue)}`,
|
||||
`Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount}${ev.waitlistEnabled ? ` | Waitlist: ${waitlistCount}` : ''} | Revenue: ${fmtAmount(totalRevenue)}`,
|
||||
'',
|
||||
'Registrations:',
|
||||
...(regRows.length ? regRows.map(r => ` ${r.name} <${r.email}> — ${r.status} — due: ${fmtAmount(r.totalDue)} paid: ${fmtAmount(r.totalPaid)} balance: ${fmtAmount(r.balance)}`) : [' (none)']),
|
||||
@@ -1154,7 +1175,7 @@ async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining
|
||||
try {
|
||||
const ticket = await loadTicketFull(ticketId);
|
||||
if (!ticket) return;
|
||||
const user = ticket.user;
|
||||
const user = ticketRecipient(ticket);
|
||||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||
const { buildWACheckIn } = require('./waMessages');
|
||||
|
||||
@@ -1437,7 +1458,10 @@ async function sendDailyEventSummaries(now = new Date()) {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now);
|
||||
const waitlistCount = ev.waitlistEnabled
|
||||
? await prisma.waitlistEntry.count({ where: { eventId: ev.id, status: { in: ['waiting', 'offered'] } } })
|
||||
: 0;
|
||||
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now, waitlistCount);
|
||||
const to = buildEventNotifyRecipientList(ev);
|
||||
if (to.length) await sendMail({ to: to.join(','), subject, html, text });
|
||||
}));
|
||||
@@ -1446,7 +1470,45 @@ async function sendDailyEventSummaries(now = new Date()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sent when a payer (or staff) asks for a named attendee to fill in their own form.
|
||||
// Always tries both channels the attendee has, regardless of anyone's preference.
|
||||
async function sendAttendeeFormLinkMessage(attendee) {
|
||||
const org = getOrg();
|
||||
const reg = attendee.registration;
|
||||
const eventTitle = reg?.event?.title || 'the event';
|
||||
const link = `${org.url}/forms?registrationId=${encodeURIComponent(attendee.registrationId)}&attendeeId=${encodeURIComponent(attendee.id)}`;
|
||||
const payer = reg?.user?.name || 'Someone';
|
||||
const sent = [];
|
||||
|
||||
if (attendee.email && attendee.email.includes('@')) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">Please fill in your form</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${attendee.name}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${payer} registered you for <strong>${eventTitle}</strong>. Please fill in your attendee form.</p>
|
||||
${ctaButton('Fill in my form', link)}
|
||||
${fallbackLink(link)}
|
||||
`, { preheader: `Your attendee form for ${eventTitle}` });
|
||||
try {
|
||||
await sendMail({ to: attendee.email, subject: `Your attendee form for ${eventTitle}`, html, text: `Hi ${attendee.name},\n\n${payer} registered you for ${eventTitle}. Please fill in your attendee form:\n${link}\n\n${org.name}` });
|
||||
sent.push('email');
|
||||
} catch (e) {
|
||||
console.warn('[attendee form link] email failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
if (attendee.phone) {
|
||||
const { waTextAny } = require('./notify');
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
if (isValidZAPhone(attendee.phone)) {
|
||||
await waTextAny({ name: attendee.name, phoneNumber: attendee.phone }, `Hi ${attendee.name}, ${payer} registered you for *${eventTitle}*. Please fill in your attendee form: ${link}`);
|
||||
sent.push('WhatsApp');
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildEventNotifyRecipientList,
|
||||
sendAttendeeFormLinkMessage,
|
||||
sendRegistrationEmails,
|
||||
sendRegistrationUpdatedEmails,
|
||||
sendPaymentEmails,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Ticket stock — the one place that decides how many places are taken.
|
||||
*
|
||||
* Taken = places on registrations that aren't cancelled + places held for a live
|
||||
* waitlist offer (until it expires). An option's stock limit covers all its variants;
|
||||
* a variant can also have its own limit. 0 means unlimited.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
/**
|
||||
* @param {{ eventOptionId: string, variantId?: string|null }} line - variantId given → that variant only
|
||||
* @param {{ excludeEntryId?: string, db?: object }} [opts] - excludeEntryId: ignore this
|
||||
* waitlist offer's hold (the person claiming it)
|
||||
*/
|
||||
async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId = null, db = prisma } = {}) {
|
||||
const scope = variantId ? { variantId } : { eventOptionId };
|
||||
const [registered, held] = await Promise.all([
|
||||
db.registrationOption.aggregate({
|
||||
where: { ...scope, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true },
|
||||
}),
|
||||
db.waitlistEntry.aggregate({
|
||||
where: {
|
||||
...scope,
|
||||
status: 'offered',
|
||||
offerExpiresAt: { gt: new Date() },
|
||||
...(excludeEntryId ? { id: { not: excludeEntryId } } : {}),
|
||||
},
|
||||
_sum: { offeredQuantity: true },
|
||||
}),
|
||||
]);
|
||||
return (registered._sum?.quantity || 0) + (held._sum?.offeredQuantity || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Places still free on a line: the tighter of the option and variant limits.
|
||||
* @returns {Promise<number|null>} null when unlimited
|
||||
*/
|
||||
async function remainingFor(option, variant, opts = {}) {
|
||||
const limits = [];
|
||||
if (option?.stockLimit > 0) {
|
||||
limits.push(option.stockLimit - await takenCount({ eventOptionId: option.id }, opts));
|
||||
}
|
||||
if (variant?.stockLimit > 0) {
|
||||
limits.push(variant.stockLimit - await takenCount({ eventOptionId: option.id, variantId: variant.id }, opts));
|
||||
}
|
||||
if (limits.length === 0) return null;
|
||||
return Math.max(0, Math.min(...limits));
|
||||
}
|
||||
|
||||
module.exports = { takenCount, remainingFor };
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Ticket transfer — handing a ticket to someone else before the event.
|
||||
*
|
||||
* - The new holder becomes the ticket's attendee (name, contact details, account). The
|
||||
* payer stays responsible for the registration; billing doesn't move.
|
||||
* - The QR code is replaced, so screenshots of the old ticket stop working. The old code
|
||||
* is kept on the TicketTransfer so scanners can say "transferred" instead of "not found".
|
||||
* - A shared (unnamed) ticket for several places has one place split off into a new
|
||||
* ticket for the new holder; the rest stay on the shared ticket and its code.
|
||||
* - The previous holder's attendee form answers are removed, and on youth events the new
|
||||
* holder needs their own parental consent.
|
||||
*/
|
||||
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const prisma = require('../config/db');
|
||||
const { validateAttendees } = require('./attendees');
|
||||
const { isMinor } = require('./consent');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
|
||||
function transferError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this ticket can't be transferred by `actor`, or null when it can. Pure.
|
||||
* @param {object} t - { userId, holderUserId, usages[], event: { startDate, allowTicketTransfer }, registrationStatus }
|
||||
* @param {object} actor - { id, role }
|
||||
*/
|
||||
function transferBlocker(t, actor, now = new Date()) {
|
||||
const supervisor = SUPERVISORS.includes(actor.role);
|
||||
if (!supervisor && t.userId !== actor.id && t.holderUserId !== actor.id) return { status: 403, message: 'Only the person who booked or holds this ticket can transfer it.' };
|
||||
if (t.registrationStatus === 'cancelled') return { status: 400, message: 'This registration was cancelled.' };
|
||||
if (new Date(t.event.startDate) <= now) return { status: 400, message: 'Tickets can only be transferred before the event starts.' };
|
||||
if ((t.usages || []).length > 0) return { status: 400, message: 'This ticket has already been used (or partly used), so it can’t be transferred.' };
|
||||
if (t.event.allowTicketTransfer === false && !supervisor) return { status: 400, message: 'Ticket transfers are turned off for this event.' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfers a ticket. Returns what the caller needs to notify people afterwards.
|
||||
* @param {object} input - { ticketId, actor, name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
|
||||
*/
|
||||
async function transferTicket({ ticketId, actor, ...holder }) {
|
||||
const { findOrCreateAccount } = require('./accountMatch');
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: ticketId },
|
||||
include: {
|
||||
usages: { select: { id: true } },
|
||||
attendee: true,
|
||||
holder: { select: { id: true, name: true } },
|
||||
user: { select: { id: true, name: true } },
|
||||
event: { include: { eventOptions: true } },
|
||||
registrationOption: { include: { registration: { select: { id: true, status: true } } } },
|
||||
},
|
||||
});
|
||||
if (!ticket) throw transferError('Ticket not found', 404);
|
||||
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
|
||||
if (blocker) throw transferError(blocker.message, blocker.status);
|
||||
|
||||
const ro = ticket.registrationOption;
|
||||
const event = ticket.event;
|
||||
// Same rules as naming an attendee at registration (incl. date of birth and parent
|
||||
// details on youth events), for exactly one place.
|
||||
const [clean] = validateAttendees({ ...event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
|
||||
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
|
||||
}]);
|
||||
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
|
||||
|
||||
const { user: toUser, created } = await findOrCreateAccount({ name: clean.name, email: clean.email, phone: clean.phone });
|
||||
const currentHolderId = ticket.attendee ? ticket.attendee.userId || ticket.holderUserId : ticket.holderUserId || ticket.userId;
|
||||
if (currentHolderId && currentHolderId === toUser.id) throw transferError(`${clean.name} already holds this ticket.`);
|
||||
|
||||
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
|
||||
const attendeeData = {
|
||||
name: clean.name, email: clean.email, phone: clean.phone, userId: toUser.id,
|
||||
dateOfBirth: clean.dateOfBirth, 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),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
@@ -1,11 +1,14 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { planLineTickets } = require('./attendees');
|
||||
|
||||
/**
|
||||
* Generate tickets for a registration when payment status is "paid".
|
||||
*
|
||||
* Rules:
|
||||
* - Exactly ONE ticket per registrationOption (keyed by eventOptionId).
|
||||
* - Each named attendee (group tickets) gets their own quantity-1 ticket; the rest of a
|
||||
* line's places share ONE quantity ticket per registrationOption (see
|
||||
* utils/attendees.js planLineTickets).
|
||||
* - If duplicate registrationOptions exist for the same eventOptionId (old data),
|
||||
* consolidate them: merge quantities, keep the one with scan history, delete the rest.
|
||||
* - If duplicate ticket records exist for the same registrationOption (old data),
|
||||
@@ -90,55 +93,40 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
});
|
||||
}));
|
||||
|
||||
// ── Step 2: For each unique option, ensure exactly one ticket with correct qty ──
|
||||
// ── Step 2: For each unique option, sync its tickets: one per named attendee, plus
|
||||
// one shared quantity ticket for any unnamed places ──
|
||||
// Re-read fresh list (some options may have been deleted above)
|
||||
const freshOptions = await prisma.registrationOption.findMany({
|
||||
where: { registrationId },
|
||||
include: {
|
||||
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } }
|
||||
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } },
|
||||
attendees: { orderBy: { order: 'asc' } },
|
||||
}
|
||||
});
|
||||
|
||||
// Each option owns disjoint tickets, so resolve them concurrently instead of one at a time.
|
||||
const perOptionResults = await Promise.all(freshOptions.map(async (option) => {
|
||||
const targetQty = option.quantity || 1;
|
||||
const existingTickets = option.tickets || [];
|
||||
|
||||
if (existingTickets.length === 0) {
|
||||
// Create one ticket
|
||||
return prisma.ticket.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
qrCode: uuidv4(),
|
||||
registrationOptionId: option.id,
|
||||
userId: registration.userId,
|
||||
eventId: registration.eventId,
|
||||
quantity: targetQty,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pick primary: prefer scanned, otherwise oldest
|
||||
const withUsages = existingTickets.filter(t => (t.usages || []).length > 0);
|
||||
const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0];
|
||||
|
||||
const updates = [];
|
||||
// Update quantity on primary if needed
|
||||
if (primary.quantity !== targetQty) {
|
||||
updates.push(prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } }));
|
||||
}
|
||||
|
||||
// Delete unscanned duplicates
|
||||
const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0);
|
||||
if (dups.length > 0) {
|
||||
updates.push(prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } }));
|
||||
}
|
||||
if (updates.length > 0) await Promise.all(updates);
|
||||
return null;
|
||||
const plan = planLineTickets({ quantity: option.quantity || 1 }, option.attendees || [], option.tickets || []);
|
||||
await Promise.all([
|
||||
plan.remove.length ? prisma.ticket.deleteMany({ where: { id: { in: plan.remove } } }) : null,
|
||||
...plan.update.map(u => prisma.ticket.update({ where: { id: u.id }, data: { ...u.data, updatedAt: new Date() } })),
|
||||
]);
|
||||
return Promise.all(plan.create.map(c => prisma.ticket.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
qrCode: uuidv4(),
|
||||
registrationOptionId: option.id,
|
||||
userId: registration.userId,
|
||||
eventId: registration.eventId,
|
||||
quantity: c.quantity,
|
||||
attendeeId: c.attendeeId,
|
||||
holderUserId: c.holderUserId,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})));
|
||||
}));
|
||||
|
||||
return perOptionResults.filter(Boolean);
|
||||
return perOptionResults.flat();
|
||||
} catch (error) {
|
||||
console.error('Error generating tickets:', error);
|
||||
throw error;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Waitlist for sold-out ticket options.
|
||||
*
|
||||
* Rules:
|
||||
* - Only on events with waitlistEnabled, and only for a line that's actually sold out.
|
||||
* - When places free up (a cancellation, a smaller booking, a raised stock limit, or an
|
||||
* offer expiring), processWaitlist offers them to the next people in line. An offer
|
||||
* holds the places for event.waitlistOfferHours — they count as taken (utils/stock.js)
|
||||
* so the public site can't sell them — and sends a claim link by email/WhatsApp.
|
||||
* - If fewer places are free than someone asked for: with waitlistPartialOffers they're
|
||||
* offered what's free; otherwise they're skipped for the next person who fits.
|
||||
* - Claiming = registering with the link (createRegistration's waitlistToken). Early-bird
|
||||
* and coupon rules apply as usual. An unclaimed offer expires and moves on.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { remainingFor } = require('./stock');
|
||||
|
||||
const ACTIVE = ['waiting', 'offered'];
|
||||
|
||||
function waitlistError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`waitlist:${token}`).digest('hex');
|
||||
|
||||
function claimLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/waitlist/claim/${token}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who gets offered what, given the free places on a line. Pure.
|
||||
* @param {Array} waiting - entries in queue order [{ id, quantity }]
|
||||
* @param {number} free - places free
|
||||
* @param {boolean} partial - offer what's free when it's less than asked for
|
||||
* @returns {Array<{ entryId: string, quantity: number }>}
|
||||
*/
|
||||
function pickOffers(waiting, free, partial) {
|
||||
const offers = [];
|
||||
let left = free;
|
||||
for (const entry of waiting) {
|
||||
if (left <= 0) break;
|
||||
if (entry.quantity <= left) {
|
||||
offers.push({ entryId: entry.id, quantity: entry.quantity });
|
||||
left -= entry.quantity;
|
||||
} else if (partial) {
|
||||
// First in line gets what's free; nobody behind them jumps the queue.
|
||||
offers.push({ entryId: entry.id, quantity: left });
|
||||
left = 0;
|
||||
}
|
||||
// else: skip to the next person who fits
|
||||
}
|
||||
return offers;
|
||||
}
|
||||
|
||||
async function notifyOffer(entry, token) {
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const user = entry.user;
|
||||
const link = claimLink(token);
|
||||
const item = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
|
||||
const until = new Date(entry.offerExpiresAt).toLocaleString('en-GB', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
const partialNote = entry.offeredQuantity < entry.quantity
|
||||
? ` You asked for ${entry.quantity}; ${entry.offeredQuantity} came free, and you can take those now.` : '';
|
||||
const line = `${entry.offeredQuantity} × ${item} for ${entry.event.title} ${entry.offeredQuantity === 1 ? 'is' : 'are'} now available for you.${partialNote} We'll hold ${entry.offeredQuantity === 1 ? 'it' : 'them'} until ${until}.`;
|
||||
|
||||
const hasEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||
const sends = [];
|
||||
if (hasEmail && shouldEmail(user)) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">A place has opened up!</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${line}</p>
|
||||
${ctaButton('Claim my place', link)}
|
||||
${fallbackLink(link)}
|
||||
`, { preheader: `Your waitlist place for ${entry.event.title}` });
|
||||
sends.push(sendMail({ to: user.email, subject: `A place is available: ${entry.event.title}`, html, text: `Hi ${user.name},\n\n${line}\n\nClaim it here: ${link}` }));
|
||||
}
|
||||
const wa = `🎟️ Hi ${user?.name || 'there'}, ${line} Claim it here: ${link}`;
|
||||
sends.push(hasEmail ? waText(user, wa) : waTextAny(user, wa));
|
||||
await Promise.allSettled(sends);
|
||||
}
|
||||
|
||||
const OFFER_INCLUDE = { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
|
||||
|
||||
/** Puts an entry on offer (inside the caller's transaction); returns the raw token. */
|
||||
async function writeOffer(db, entry, quantity, hours) {
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
await db.waitlistEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
status: 'offered',
|
||||
offeredQuantity: quantity,
|
||||
offerTokenHash: hashToken(token),
|
||||
offeredAt: new Date(),
|
||||
offerExpiresAt: new Date(Date.now() + (hours || 24) * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
async function notifyOffers(offers) {
|
||||
for (const { entryId, token } of offers) {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: OFFER_INCLUDE });
|
||||
if (entry) notifyOffer(entry, token).catch(e => console.warn('[waitlist] notify failed:', e?.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Offers `quantity` places to one entry (e.g. a supervisor's manual offer) and notifies. */
|
||||
async function makeOffer(entryId, quantity) {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true } });
|
||||
const token = await writeOffer(prisma, entry, quantity, entry.event.waitlistOfferHours);
|
||||
await notifyOffers([{ entryId, token }]);
|
||||
return prisma.waitlistEntry.findUnique({ where: { id: entryId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers any free places on an event to the people waiting for them. Safe to call any
|
||||
* time places might have freed up; runs one event at a time under an advisory lock.
|
||||
* @returns {Promise<number>} offers made
|
||||
*/
|
||||
async function processWaitlist(eventId) {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { variants: true } } },
|
||||
});
|
||||
if (!event || !event.waitlistEnabled || !event.isActive || event.cashupStatus === 'closed') return 0;
|
||||
if (new Date(event.endDate) < new Date()) return 0;
|
||||
|
||||
const made = [];
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'waitlist:' + eventId}))`;
|
||||
const waiting = await tx.waitlistEntry.findMany({ where: { eventId, status: 'waiting' }, orderBy: { position: 'asc' } });
|
||||
const lines = [...new Map(waiting.map(w => [`${w.eventOptionId}::${w.variantId || ''}`, w])).values()];
|
||||
for (const sample of lines) {
|
||||
const option = event.eventOptions.find(o => o.id === sample.eventOptionId);
|
||||
const variant = sample.variantId ? (option?.variants || []).find(v => v.id === sample.variantId) : null;
|
||||
if (!option) continue;
|
||||
const free = await remainingFor(option, variant, { db: tx });
|
||||
if (free === null || free <= 0) continue;
|
||||
const queue = waiting.filter(w => w.eventOptionId === sample.eventOptionId && (w.variantId || null) === (sample.variantId || null));
|
||||
for (const offer of pickOffers(queue, free, event.waitlistPartialOffers)) {
|
||||
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, event.waitlistOfferHours);
|
||||
made.push({ entryId: offer.entryId, token });
|
||||
}
|
||||
}
|
||||
}, { timeout: 20000 });
|
||||
await notifyOffers(made);
|
||||
return made.length;
|
||||
}
|
||||
|
||||
/** Marks lapsed offers as expired. @returns event ids that now have places to re-offer. */
|
||||
async function expireOffers(now = new Date()) {
|
||||
const lapsed = await prisma.waitlistEntry.findMany({ where: { status: 'offered', offerExpiresAt: { lte: now } }, select: { id: true, eventId: true } });
|
||||
if (lapsed.length === 0) return [];
|
||||
await prisma.waitlistEntry.updateMany({ where: { id: { in: lapsed.map(l => l.id) } }, data: { status: 'expired', offerTokenHash: null } });
|
||||
return [...new Set(lapsed.map(l => l.eventId))];
|
||||
}
|
||||
|
||||
/** Background job: expire lapsed offers, then re-offer free places everywhere waiting. */
|
||||
async function runWaitlistJob() {
|
||||
const expiredEvents = await expireOffers();
|
||||
const waitingEvents = await prisma.waitlistEntry.findMany({ where: { status: 'waiting' }, distinct: ['eventId'], select: { eventId: true } });
|
||||
const ids = new Set([...expiredEvents, ...waitingEvents.map(w => w.eventId)]);
|
||||
let offers = 0;
|
||||
for (const id of ids) offers += await processWaitlist(id).catch(() => 0);
|
||||
return { expired: expiredEvents.length, offers };
|
||||
}
|
||||
|
||||
/** Fire-and-forget re-offer after something freed places on an event. */
|
||||
function queueWaitlist(eventId) {
|
||||
if (!eventId) return;
|
||||
processWaitlist(eventId).catch(e => console.warn('[waitlist] process failed:', e?.message || e));
|
||||
}
|
||||
|
||||
/** A live offer from its claim token, or null. */
|
||||
async function findOfferByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 100) return null;
|
||||
return prisma.waitlistEntry.findUnique({
|
||||
where: { offerTokenHash: hashToken(token) },
|
||||
include: { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACTIVE,
|
||||
waitlistError,
|
||||
hashToken,
|
||||
pickOffers,
|
||||
makeOffer,
|
||||
processWaitlist,
|
||||
expireOffers,
|
||||
runWaitlistJob,
|
||||
queueWaitlist,
|
||||
findOfferByToken,
|
||||
};
|
||||
@@ -0,0 +1,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' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
const { validateAttendees, planLineTickets, lineKey } = require('../src/utils/attendees');
|
||||
|
||||
const EVENT = {
|
||||
allowNamedAttendees: true,
|
||||
eventOptions: [
|
||||
{ id: 'kid', name: 'Camper', isMainTicket: true },
|
||||
{ id: 'shirt', name: 'T-shirt', isMainTicket: false },
|
||||
],
|
||||
};
|
||||
const places = (n) => ({ [lineKey('kid', null)]: n });
|
||||
|
||||
describe('validateAttendees', () => {
|
||||
test('cleans up names and contact details', () => {
|
||||
const out = validateAttendees(EVENT, places(2), [
|
||||
{ eventOptionId: 'kid', name: ' Sam ', email: ' ', phone: '0821234567' },
|
||||
{ eventOptionId: 'kid', name: 'Alex', email: 'alex@example.com' },
|
||||
]);
|
||||
expect(out).toMatchObject([
|
||||
{ eventOptionId: 'kid', variantId: null, name: 'Sam', email: null, phone: '0821234567' },
|
||||
{ eventOptionId: 'kid', variantId: null, name: 'Alex', email: 'alex@example.com', phone: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('nothing to check when no attendees are given', () => {
|
||||
expect(validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [])).toEqual([]);
|
||||
});
|
||||
|
||||
test('refuses names on events that do not take them', () => {
|
||||
expect(() => validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [{ eventOptionId: 'kid', name: 'Sam' }])).toThrow(/doesn't take attendee names/);
|
||||
});
|
||||
|
||||
test('only main tickets can be named', () => {
|
||||
expect(() => validateAttendees(EVENT, { [lineKey('shirt', null)]: 1 }, [{ eventOptionId: 'shirt', name: 'Sam' }])).toThrow(/main tickets/);
|
||||
});
|
||||
|
||||
test('needs a name and a valid email', () => {
|
||||
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: ' ' }])).toThrow(/enter a name/);
|
||||
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'Sam', email: 'nope' }])).toThrow(/valid email/);
|
||||
});
|
||||
|
||||
test('no more names than places, counting names already given', () => {
|
||||
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'A' }, { eventOptionId: 'kid', name: 'B' }])).toThrow(/More names/);
|
||||
expect(() => validateAttendees(EVENT, places(3), [{ eventOptionId: 'kid', name: 'C' }], places(2))).not.toThrow();
|
||||
expect(() => validateAttendees(EVENT, places(2), [{ eventOptionId: 'kid', name: 'C' }], places(2))).toThrow(/More names/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planLineTickets', () => {
|
||||
const scan = (q = 1) => [{ quantityRedeemed: q }];
|
||||
|
||||
test('no attendees: one shared ticket for the whole quantity', () => {
|
||||
expect(planLineTickets({ quantity: 3 }, [], [])).toEqual({ create: [{ attendeeId: null, holderUserId: null, quantity: 3 }], update: [], remove: [] });
|
||||
});
|
||||
|
||||
test('every attendee gets their own ticket; unnamed places share one', () => {
|
||||
const plan = planLineTickets({ quantity: 3 }, [{ id: 'a1', userId: 'u9' }, { id: 'a2', userId: null }], []);
|
||||
expect(plan.create).toEqual([
|
||||
{ attendeeId: 'a1', holderUserId: 'u9', quantity: 1 },
|
||||
{ attendeeId: 'a2', holderUserId: null, quantity: 1 },
|
||||
{ attendeeId: null, holderUserId: null, quantity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('naming every place replaces the shared ticket', () => {
|
||||
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: [] }]);
|
||||
expect(plan.remove).toEqual(['t0']);
|
||||
expect(plan.create).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a scanned shared ticket is kept and never shrunk below what was used', () => {
|
||||
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: scan(1) }]);
|
||||
expect(plan.remove).toEqual([]);
|
||||
expect(plan.update).toEqual([{ id: 't0', data: { quantity: 1 } }]);
|
||||
});
|
||||
|
||||
test('removing an attendee removes their unscanned ticket', () => {
|
||||
const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1' }], [
|
||||
{ id: 't1', attendeeId: 'a1', quantity: 1, usages: [] },
|
||||
{ id: 't2', attendeeId: 'gone', quantity: 1, usages: [] },
|
||||
]);
|
||||
expect(plan).toEqual({ create: [], update: [], remove: ['t2'] });
|
||||
});
|
||||
|
||||
test("keeps a scanned ticket whose attendee was removed, and counts it as a used place", () => {
|
||||
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }], [
|
||||
{ id: 't1', attendeeId: 'a1', quantity: 1, usages: [] },
|
||||
{ id: 't2', attendeeId: 'gone', quantity: 1, usages: scan() },
|
||||
]);
|
||||
expect(plan).toEqual({ create: [], update: [], remove: [] });
|
||||
});
|
||||
|
||||
test('follows the attendee to a newly linked account', () => {
|
||||
const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1', userId: 'u2' }], [{ id: 't1', attendeeId: 'a1', quantity: 1, holderUserId: null, usages: [] }]);
|
||||
expect(plan.update).toEqual([{ id: 't1', data: { holderUserId: 'u2', quantity: 1 } }]);
|
||||
});
|
||||
|
||||
test('existing single shared ticket is resized, duplicates dropped', () => {
|
||||
const plan = planLineTickets({ quantity: 4 }, [], [
|
||||
{ id: 't1', attendeeId: null, quantity: 2, usages: [] },
|
||||
{ id: 't2', attendeeId: null, quantity: 2, usages: [] },
|
||||
]);
|
||||
expect(plan).toEqual({ create: [], update: [{ id: 't1', data: { quantity: 4 } }], remove: ['t2'] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
|
||||
const { ageOn, isMinor, consentStatusFor, reminderDue, validateConsentAnswer, hashToken } = require('../src/utils/consent');
|
||||
const { validateAttendees, lineKey } = require('../src/utils/attendees');
|
||||
|
||||
const START = new Date('2026-12-10T08:00:00Z');
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('ageOn / isMinor', () => {
|
||||
test('counts whole years, turning a year older on the birthday', () => {
|
||||
expect(ageOn('2008-12-10', START)).toBe(18);
|
||||
expect(ageOn('2008-12-11', START)).toBe(17);
|
||||
expect(ageOn('2010-01-01', START)).toBe(16);
|
||||
});
|
||||
|
||||
test('under 18 on the event start date', () => {
|
||||
expect(isMinor('2008-12-11', START)).toBe(true);
|
||||
expect(isMinor('2008-12-10', START)).toBe(false);
|
||||
expect(isMinor(null, START)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consentStatusFor', () => {
|
||||
const event = { requiresParentalConsent: true, startDate: START };
|
||||
const kid = { dateOfBirth: '2012-05-01' };
|
||||
|
||||
test('only needed on youth events, for minors', () => {
|
||||
expect(consentStatusFor(kid, { ...event, requiresParentalConsent: false }, null)).toBe('not_required');
|
||||
expect(consentStatusFor({ dateOfBirth: '1990-01-01' }, event, null)).toBe('not_required');
|
||||
});
|
||||
|
||||
test('a minor with no request, or an unnamed ticket, is missing consent', () => {
|
||||
expect(consentStatusFor(kid, event, null)).toBe('missing');
|
||||
expect(consentStatusFor(null, event, null)).toBe('missing');
|
||||
});
|
||||
|
||||
test('otherwise the latest request decides', () => {
|
||||
expect(consentStatusFor(kid, event, { status: 'pending' })).toBe('pending');
|
||||
expect(consentStatusFor(kid, event, { status: 'given' })).toBe('given');
|
||||
expect(consentStatusFor(kid, event, { status: 'declined' })).toBe('declined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reminderDue', () => {
|
||||
const now = new Date('2026-12-01T10:00:00Z');
|
||||
const req = (over) => ({ status: 'pending', remindersSent: 0, lastSentAt: new Date(now.getTime() - 1 * DAY), expiresAt: START, ...over });
|
||||
|
||||
test('first reminder 2 days after the request', () => {
|
||||
expect(reminderDue(req({}), now)).toBe(false);
|
||||
expect(reminderDue(req({ lastSentAt: new Date(now.getTime() - 2 * DAY) }), now)).toBe(true);
|
||||
});
|
||||
|
||||
test('then every 3 days', () => {
|
||||
expect(reminderDue(req({ remindersSent: 1, lastSentAt: new Date(now.getTime() - 2 * DAY) }), now)).toBe(false);
|
||||
expect(reminderDue(req({ remindersSent: 1, lastSentAt: new Date(now.getTime() - 3 * DAY) }), now)).toBe(true);
|
||||
});
|
||||
|
||||
test('never once answered or expired', () => {
|
||||
expect(reminderDue(req({ status: 'given', lastSentAt: new Date(0) }), now)).toBe(false);
|
||||
expect(reminderDue(req({ lastSentAt: new Date(0), expiresAt: new Date(now.getTime() - 1) }), now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConsentAnswer', () => {
|
||||
const event = { consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true };
|
||||
const ok = { decision: 'give', signedName: 'Pat Parent', isGuardian: true, medical: { allergies: 'Peanuts' }, emergency: { name: 'Gran', phone: '0821234567' }, photoConsent: false };
|
||||
|
||||
test('accepts a complete answer and keeps only known fields', () => {
|
||||
const out = validateConsentAnswer({ ...ok, extra: 'x' }, event);
|
||||
expect(out).toEqual({
|
||||
decision: 'give', signedName: 'Pat Parent',
|
||||
medical: { allergies: 'Peanuts', medication: '', conditions: '', medicalAid: '' },
|
||||
emergency: { name: 'Gran', phone: '0821234567', relationship: '' },
|
||||
photoConsent: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('needs a decision, a typed name and the guardian tick', () => {
|
||||
expect(() => validateConsentAnswer({ ...ok, decision: 'maybe' }, event)).toThrow(/give or decline/);
|
||||
expect(() => validateConsentAnswer({ ...ok, signedName: ' ' }, event)).toThrow(/full name/);
|
||||
expect(() => validateConsentAnswer({ ...ok, isGuardian: false }, event)).toThrow(/parent or legal guardian/);
|
||||
});
|
||||
|
||||
test('needs an emergency contact and a photo answer when asked for', () => {
|
||||
expect(() => validateConsentAnswer({ ...ok, emergency: {} }, event)).toThrow(/emergency contact/);
|
||||
expect(() => validateConsentAnswer({ ...ok, photoConsent: undefined }, event)).toThrow(/photo/);
|
||||
});
|
||||
|
||||
test('declining needs no extra details', () => {
|
||||
expect(validateConsentAnswer({ decision: 'decline', signedName: 'Pat', isGuardian: true }, event))
|
||||
.toEqual({ decision: 'decline', signedName: 'Pat', medical: null, emergency: null, photoConsent: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashToken', () => {
|
||||
test('is stable and never the token itself', () => {
|
||||
expect(hashToken('abc')).toBe(hashToken('abc'));
|
||||
expect(hashToken('abc')).not.toContain('abc');
|
||||
expect(hashToken('abc')).not.toBe(hashToken('abd'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAttendees on a youth event', () => {
|
||||
const event = {
|
||||
requiresParentalConsent: true, allowNamedAttendees: true, startDate: START,
|
||||
eventOptions: [{ id: 'kid', name: 'Camper', isMainTicket: true }, { id: 'shirt', name: 'T-shirt', isMainTicket: false }],
|
||||
};
|
||||
const places = { [lineKey('kid', null)]: 2, [lineKey('shirt', null)]: 1 };
|
||||
const parent = { parentName: 'Pat', parentEmail: 'pat@example.com', parentPhone: '0821234567' };
|
||||
|
||||
test('every main place must be named', () => {
|
||||
expect(() => validateAttendees(event, places, [{ eventOptionId: 'kid', name: 'A', dateOfBirth: '1990-01-01' }])).toThrow(/every "Camper" ticket/);
|
||||
});
|
||||
|
||||
test('a date of birth is required', () => {
|
||||
expect(() => validateAttendees(event, places, [{ eventOptionId: 'kid', name: 'A' }, { eventOptionId: 'kid', name: 'B' }])).toThrow(/date of birth/);
|
||||
});
|
||||
|
||||
test('minors need full parent details; adults do not', () => {
|
||||
expect(() => validateAttendees(event, places, [
|
||||
{ eventOptionId: 'kid', name: 'Kid', dateOfBirth: '2014-03-03', parentName: 'Pat' },
|
||||
{ eventOptionId: 'kid', name: 'Leader', dateOfBirth: '1990-01-01' },
|
||||
])).toThrow(/under 18/);
|
||||
const out = validateAttendees(event, places, [
|
||||
{ eventOptionId: 'kid', name: 'Kid', dateOfBirth: '2014-03-03', ...parent },
|
||||
{ eventOptionId: 'kid', name: 'Leader', dateOfBirth: '1990-01-01' },
|
||||
]);
|
||||
expect(out[0]).toMatchObject({ name: 'Kid', parentEmail: 'pat@example.com' });
|
||||
expect(out[0].dateOfBirth).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test('a date of birth in the future is refused', () => {
|
||||
expect(() => validateAttendees(event, { [lineKey('kid', null)]: 1 }, [{ eventOptionId: 'kid', name: 'A', dateOfBirth: '2999-01-01' }])).toThrow(/valid date of birth/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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' });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const { pickOffers } = require('../src/utils/waitlist');
|
||||
|
||||
const q = (...qs) => qs.map((quantity, i) => ({ id: `e${i + 1}`, quantity }));
|
||||
|
||||
describe('pickOffers', () => {
|
||||
test('offers people in order while places last', () => {
|
||||
expect(pickOffers(q(1, 2, 1), 3, true)).toEqual([
|
||||
{ entryId: 'e1', quantity: 1 },
|
||||
{ entryId: 'e2', quantity: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('nothing free, no offers', () => {
|
||||
expect(pickOffers(q(1), 0, true)).toEqual([]);
|
||||
});
|
||||
|
||||
test('partial offers: the first in line gets what is free, nobody jumps ahead', () => {
|
||||
expect(pickOffers(q(3, 1), 2, true)).toEqual([{ entryId: 'e1', quantity: 2 }]);
|
||||
});
|
||||
|
||||
test('without partial offers, skips to the next person who fits', () => {
|
||||
expect(pickOffers(q(3, 1, 1), 2, false)).toEqual([
|
||||
{ entryId: 'e2', quantity: 1 },
|
||||
{ entryId: 'e3', quantity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('without partial offers, nobody fits', () => {
|
||||
expect(pickOffers(q(3, 4), 2, false)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Generated
+49
-62
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.13.0",
|
||||
"version": "1.20.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.13.0",
|
||||
"version": "1.20.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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.13.0",
|
||||
"version": "1.20.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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
})());
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { ShieldCheck, CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
type ConsentInfo = {
|
||||
status: "pending" | "given" | "declined";
|
||||
superseded: boolean;
|
||||
expired: boolean;
|
||||
respondedAt: string | null;
|
||||
attendeeName: string;
|
||||
parentName: string | null;
|
||||
consentText: string;
|
||||
event: { title: string; startDate: string; endDate: string; location: string | null };
|
||||
collect: { medical: boolean; emergency: boolean; photo: boolean };
|
||||
};
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
/** Public page a parent/guardian opens from their consent email or WhatsApp. */
|
||||
export default function ConsentPage() {
|
||||
const params = useParams<{ token: string }>();
|
||||
const token = params?.token;
|
||||
const [info, setInfo] = useState<ConsentInfo | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState<"given" | "declined" | null>(null);
|
||||
|
||||
const [signedName, setSignedName] = useState("");
|
||||
const [isGuardian, setIsGuardian] = useState(false);
|
||||
const [medical, setMedical] = useState({ allergies: "", medication: "", conditions: "", medicalAid: "" });
|
||||
const [emergency, setEmergency] = useState({ name: "", phone: "", relationship: "" });
|
||||
const [photoConsent, setPhotoConsent] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<ConsentInfo>(`/api/consent/${encodeURIComponent(token)}`)
|
||||
.then(res => { setInfo(res); setSignedName(res.parentName || ""); })
|
||||
.catch(e => setLoadError(e instanceof ApiError ? e.message : "Couldn't load this consent request."));
|
||||
}, [token]);
|
||||
|
||||
const submit = async (decision: "give" | "decline") => {
|
||||
if (!token || !info) return;
|
||||
setError(null);
|
||||
if (decision === "give" && info.collect.photo && photoConsent === null) { setError("Please answer the photo consent question."); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch(`/api/consent/${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
decision, signedName, isGuardian,
|
||||
...(decision === "give" && info.collect.medical ? { medical } : {}),
|
||||
...(decision === "give" && info.collect.emergency ? { emergency } : {}),
|
||||
...(decision === "give" && info.collect.photo ? { photoConsent } : {}),
|
||||
},
|
||||
});
|
||||
setDone(decision === "give" ? "given" : "declined");
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Couldn't save your answer. Please try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const open = info && info.status === "pending" && !info.superseded && !info.expired && !done;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Parental consent</h1>
|
||||
</div>
|
||||
|
||||
{loadError && <div className="border rounded-xl p-4 bg-red-50 text-red-800 text-sm">{loadError}</div>}
|
||||
{!info && !loadError && <div className="text-sm text-gray-600">Loading…</div>}
|
||||
|
||||
{info && (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Event</div>
|
||||
<div className="font-semibold text-gray-900">{info.event.title}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{formatDate(new Date(info.event.startDate))}{info.event.endDate ? ` – ${formatDate(new Date(info.event.endDate))}` : ""}
|
||||
{info.event.location ? ` · ${info.event.location}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Attendee</div>
|
||||
<div className="font-semibold text-gray-900">{info.attendeeName}</div>
|
||||
</div>
|
||||
|
||||
{(done || info.status !== "pending") && (
|
||||
<div className={`flex items-start gap-2 rounded-lg p-3 text-sm ${(done || info.status) === "given" ? "bg-green-50 text-green-800" : "bg-amber-50 text-amber-900"}`}>
|
||||
{(done || info.status) === "given" ? <CheckCircle2 className="w-5 h-5 shrink-0" /> : <XCircle className="w-5 h-5 shrink-0" />}
|
||||
<span>
|
||||
{(done || info.status) === "given" ? "Consent has been given. Thank you!" : "Consent has been declined. The organisers have been told."}
|
||||
{!done && info.respondedAt && ` (${formatDate(new Date(info.respondedAt))})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!done && info.status === "pending" && info.superseded && (
|
||||
<div className="rounded-lg p-3 text-sm bg-amber-50 text-amber-900">A newer consent request has replaced this one. Please use the link in your latest message.</div>
|
||||
)}
|
||||
{!done && info.status === "pending" && info.expired && (
|
||||
<div className="rounded-lg p-3 text-sm bg-gray-100 text-gray-700">This consent link has expired because the event has ended.</div>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-800 mb-1">Please read</div>
|
||||
<div className="text-sm text-gray-700 whitespace-pre-line border rounded-lg p-3 bg-gray-50 max-h-72 overflow-y-auto">{info.consentText || "The organisers haven't added any consent text."}</div>
|
||||
</div>
|
||||
|
||||
{info.collect.medical && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium text-gray-800">Medical information <span className="font-normal text-gray-500">(kept confidential and deleted after the event)</span></legend>
|
||||
<textarea className={inputCls} rows={2} placeholder="Allergies" value={medical.allergies} onChange={e => setMedical({ ...medical, allergies: e.target.value })} aria-label="Allergies" />
|
||||
<textarea className={inputCls} rows={2} placeholder="Medication" value={medical.medication} onChange={e => setMedical({ ...medical, medication: e.target.value })} aria-label="Medication" />
|
||||
<textarea className={inputCls} rows={2} placeholder="Medical conditions" value={medical.conditions} onChange={e => setMedical({ ...medical, conditions: e.target.value })} aria-label="Medical conditions" />
|
||||
<input className={inputCls} placeholder="Medical aid (scheme and number)" value={medical.medicalAid} onChange={e => setMedical({ ...medical, medicalAid: e.target.value })} aria-label="Medical aid" />
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{info.collect.emergency && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium text-gray-800">Emergency contact</legend>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input className={inputCls} placeholder="Name" value={emergency.name} onChange={e => setEmergency({ ...emergency, name: e.target.value })} aria-label="Emergency contact name" />
|
||||
<input type="tel" className={inputCls} placeholder="Phone" value={emergency.phone} onChange={e => setEmergency({ ...emergency, phone: e.target.value })} aria-label="Emergency contact phone" />
|
||||
<input className={inputCls} placeholder="Relationship" value={emergency.relationship} onChange={e => setEmergency({ ...emergency, relationship: e.target.value })} aria-label="Relationship" />
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{info.collect.photo && (
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium text-gray-800 mb-1">May {info.attendeeName} appear in event photos and videos?</legend>
|
||||
<label className="inline-flex items-center gap-2 text-sm mr-4"><input type="radio" name="photo" checked={photoConsent === true} onChange={() => setPhotoConsent(true)} /> Yes</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm"><input type="radio" name="photo" checked={photoConsent === false} onChange={() => setPhotoConsent(false)} /> No</label>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<label className="block text-sm font-medium text-gray-800">Type your full name to sign</label>
|
||||
<input className={inputCls} value={signedName} onChange={e => setSignedName(e.target.value)} aria-label="Your full name" />
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" className="mt-0.5" checked={isGuardian} onChange={e => setIsGuardian(e.target.checked)} />
|
||||
<span>I am the parent or legal guardian of {info.attendeeName}.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button onClick={() => submit("give")} disabled={busy || !isGuardian || signedName.trim().length < 2} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{busy ? "Saving…" : "Give consent"}
|
||||
</button>
|
||||
<button onClick={() => submit("decline")} disabled={busy || !isGuardian || signedName.trim().length < 2} className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 disabled:opacity-50">
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">We record your typed name, the time, and the device you used, together with the text above.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,14 @@ const ACTIONS = [
|
||||
"coupon_updated",
|
||||
"coupon_disabled",
|
||||
"coupon_applied_manual",
|
||||
"consent_override",
|
||||
"consent_rerequested",
|
||||
"medical_list_viewed",
|
||||
"scan_lock_acquired",
|
||||
"scan_lock_released",
|
||||
"scan_lock_force_released",
|
||||
"offline_sync",
|
||||
"ticket_transferred",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
@@ -38,6 +46,14 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
coupon_updated: "Coupon edited",
|
||||
coupon_disabled: "Coupon turned off",
|
||||
coupon_applied_manual: "Coupon applied/removed by staff",
|
||||
consent_override: "Consent overridden at check-in",
|
||||
consent_rerequested: "Consent re-requested",
|
||||
medical_list_viewed: "Medical list viewed",
|
||||
scan_lock_acquired: "Offline scanning started",
|
||||
scan_lock_released: "Offline scanning ended",
|
||||
scan_lock_force_released: "Offline lock force-released",
|
||||
offline_sync: "Offline scans synced",
|
||||
ticket_transferred: "Ticket transferred",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
|
||||
@@ -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,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -27,6 +27,10 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/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 },
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
|
||||
|
||||
@@ -136,6 +136,7 @@ function SiteSettingsPageInner() {
|
||||
const [legalIoEmail, setLegalIoEmail] = useState("");
|
||||
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
|
||||
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
|
||||
const [consentRetentionDays, setConsentRetentionDays] = useState("30");
|
||||
|
||||
// ── Load all settings once ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -171,6 +172,7 @@ function SiteSettingsPageInner() {
|
||||
setLegalIoEmail(s.legal_io_email || "");
|
||||
setLegalWebsiteUrl(s.legal_website_url || "");
|
||||
setLegalEffectiveDate(s.legal_effective_date || "");
|
||||
setConsentRetentionDays(s.consent_medical_retention_days || "30");
|
||||
})
|
||||
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
|
||||
.finally(() => setLoadingInitial(false));
|
||||
@@ -319,6 +321,7 @@ function SiteSettingsPageInner() {
|
||||
legal_io_email: legalIoEmail.trim(),
|
||||
legal_website_url: legalWebsiteUrl.trim(),
|
||||
legal_effective_date: legalEffectiveDate.trim(),
|
||||
consent_medical_retention_days: String(Math.max(0, parseInt(consentRetentionDays, 10) || 0)),
|
||||
});
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
@@ -658,6 +661,15 @@ function SiteSettingsPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">Youth events</p>
|
||||
<Field label="Delete medical & emergency details this many days after an event">
|
||||
<input type="number" min="0" className={`${inputCls} sm:w-32`}
|
||||
value={consentRetentionDays} onChange={e => setConsentRetentionDays(e.target.value)} />
|
||||
</Field>
|
||||
<p className="text-xs text-gray-500 mt-1">Collected on parental consent forms. The consent record itself (who agreed, and when) is kept.</p>
|
||||
</div>
|
||||
|
||||
<SaveBar saving={saving} onSave={saveLegal} result={result} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -145,7 +145,7 @@ function EventTicketsContent() {
|
||||
const optVariant = t.registrationOption?.variant?.name;
|
||||
const type = optVariant ? `${optBase} — ${optVariant}` : optBase;
|
||||
const qty = t.quantity || 1;
|
||||
const holder = t.user?.name || t.userId || "";
|
||||
const holder = t.attendee?.name ? `${t.attendee.name}${t.user?.name ? ` (booked by ${t.user.name})` : ""}` : (t.user?.name || t.userId || "");
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent(
|
||||
t.qrCode || t.id
|
||||
)}`;
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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,8 +38,12 @@ export default function TicketScanningPage() {
|
||||
qrCode: string;
|
||||
ticket: any;
|
||||
remaining: number;
|
||||
offlineTicket?: SnapshotTicket; // scanned against the downloaded list (offline mode)
|
||||
} | null>(null);
|
||||
const [confirmQty, setConfirmQty] = useState(1);
|
||||
// Youth events: the preview says whether parental consent allows check-in.
|
||||
const consentBlocked = !!confirmModal && !['not_required', 'given', undefined].includes(confirmModal.ticket?.consentStatus);
|
||||
const canOverrideConsent = user?.role === 'admin' || user?.role === 'supervisor';
|
||||
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
|
||||
|
||||
// Event selection state
|
||||
@@ -93,6 +101,16 @@ export default function TicketScanningPage() {
|
||||
})();
|
||||
}, [selectedEventId, token]);
|
||||
|
||||
// In offline mode the event and section are fixed to the locked scope.
|
||||
useEffect(() => {
|
||||
if (!offline.lock) return;
|
||||
setSelectedEventId(offline.lock.eventId);
|
||||
}, [offline.lock]);
|
||||
useEffect(() => {
|
||||
if (!offline.lock) return;
|
||||
setSelectedSectionId(offline.lock.sectionId || "all");
|
||||
}, [offline.lock, sections]);
|
||||
|
||||
// Bug fix: stop the scanner when the event or section filter changes so stale scan
|
||||
// context is never used. The user must press "Scan Ticket" again to resume.
|
||||
const isFirstFilterRender = useRef(true);
|
||||
@@ -111,6 +129,32 @@ export default function TicketScanningPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Offline mode: check the ticket against the list downloaded to this device.
|
||||
if (offline.lock) {
|
||||
const t = await offline.findTicket(text);
|
||||
if (!t) { setErrorModal("Not found in the offline ticket list. Check at the door desk."); return; }
|
||||
if (t.remaining <= 0) { setAlreadyUsedModal({ message: "Ticket has already been fully used", ticket: { id: t.id } }); return; }
|
||||
if (!["not_required", "given"].includes(t.consentStatus)) {
|
||||
setErrorModal(t.consentStatus === "declined" ? "Parental consent was declined — this ticket can't be checked in." : "Parental consent is missing or pending — this ticket can't be checked in offline. Check at the door desk.");
|
||||
return;
|
||||
}
|
||||
setConfirmQty(t.remaining);
|
||||
setConfirmQtyRaw(String(t.remaining));
|
||||
setConfirmModal({
|
||||
qrCode: text,
|
||||
remaining: t.remaining,
|
||||
offlineTicket: t,
|
||||
ticket: {
|
||||
id: t.id, quantity: t.quantity, consentStatus: t.consentStatus,
|
||||
event: { title: offline.lock.eventTitle },
|
||||
registrationOption: { eventOption: { name: t.option }, variant: t.variant ? { name: t.variant } : null },
|
||||
attendee: t.bookedBy ? { name: t.holder } : null,
|
||||
user: { name: t.bookedBy || t.holder },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Always preview first to validate the ticket before showing confirm step
|
||||
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
|
||||
let preview: any;
|
||||
@@ -165,11 +209,11 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function commitScan() {
|
||||
async function commitScan(overrideConsent = false) {
|
||||
if (!confirmModal || !token) return;
|
||||
|
||||
// Capture everything before clearing modal state
|
||||
const { qrCode, ticket, remaining } = confirmModal;
|
||||
const { qrCode, ticket, remaining, offlineTicket } = confirmModal;
|
||||
const qty = confirmQty;
|
||||
const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket";
|
||||
const variantName = ticket?.registrationOption?.variant?.name;
|
||||
@@ -191,6 +235,17 @@ export default function TicketScanningPage() {
|
||||
total: ticket?.quantity || 1,
|
||||
});
|
||||
|
||||
// Offline mode: saved on this device, synced when there's a connection.
|
||||
if (offlineTicket) {
|
||||
try {
|
||||
await offline.scanLocally(offlineTicket, qty);
|
||||
} catch (e: any) {
|
||||
setSuccessModal(null);
|
||||
setErrorModal(e?.message || "Couldn't save the scan on this device");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Background write — reverse the optimistic update on failure
|
||||
try {
|
||||
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
|
||||
@@ -199,7 +254,12 @@ export default function TicketScanningPage() {
|
||||
await apiFetch<any>(url, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { qty },
|
||||
body: {
|
||||
qty,
|
||||
deviceId: offline.deviceId(),
|
||||
...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}),
|
||||
...(overrideConsent ? { overrideConsent: true } : {}),
|
||||
},
|
||||
});
|
||||
refreshStatsAndRecent();
|
||||
} catch (e: any) {
|
||||
@@ -267,8 +327,10 @@ export default function TicketScanningPage() {
|
||||
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
|
||||
value={selectedEventId}
|
||||
onChange={(e) => setSelectedEventId(e.target.value)}
|
||||
disabled={!!offline.lock}
|
||||
>
|
||||
<option value="all">All events</option>
|
||||
{offline.lock && !events.some(ev => ev.id === offline.lock!.eventId) && <option value={offline.lock.eventId}>{offline.lock.eventTitle}</option>}
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
@@ -286,6 +348,7 @@ export default function TicketScanningPage() {
|
||||
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
|
||||
value={selectedSectionId}
|
||||
onChange={(e) => setSelectedSectionId(e.target.value)}
|
||||
disabled={!!offline.lock}
|
||||
>
|
||||
<option value="all">All sections</option>
|
||||
{sections.map(section => (
|
||||
@@ -296,6 +359,16 @@ export default function TicketScanningPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OfflineScanPanel
|
||||
offline={offline}
|
||||
token={token}
|
||||
eventId={selectedEventId}
|
||||
eventTitle={events.find(ev => ev.id === selectedEventId)?.title || offline.lock?.eventTitle || ""}
|
||||
sectionId={selectedSectionId !== "all" ? selectedSectionId : null}
|
||||
sectionName={sections.find(s => s.id === selectedSectionId)?.name || null}
|
||||
onError={setError}
|
||||
onInfo={setScanInfo}
|
||||
/>
|
||||
<QRScanner
|
||||
ref={scannerRef}
|
||||
onResult={handleResult}
|
||||
@@ -375,11 +448,25 @@ export default function TicketScanningPage() {
|
||||
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
|
||||
<div><span className="font-medium">Ticket type:</span> {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}</div>
|
||||
<div><span className="font-medium">Event:</span> {confirmModal.ticket?.event?.title || '—'}</div>
|
||||
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
|
||||
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.attendee?.name || confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
|
||||
{confirmModal.ticket?.attendee?.name && confirmModal.ticket?.user?.name && (
|
||||
<div className="text-xs text-gray-500">Booked by {confirmModal.ticket.user.name}</div>
|
||||
)}
|
||||
<div><span className="font-medium">Qty on ticket:</span> {confirmModal.ticket?.quantity || 1}</div>
|
||||
<div><span className="font-medium">Remaining:</span> <span className={confirmModal.remaining < (confirmModal.ticket?.quantity || 1) ? 'text-amber-600 font-semibold' : 'text-green-700 font-semibold'}>{confirmModal.remaining}</span></div>
|
||||
</div>
|
||||
|
||||
{consentBlocked && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<div className="font-semibold">
|
||||
{confirmModal.ticket?.consentStatus === 'declined' ? 'Parental consent was declined'
|
||||
: confirmModal.ticket?.consentStatus === 'pending' ? 'Parental consent is still pending'
|
||||
: 'Parental consent is missing'}
|
||||
</div>
|
||||
<div className="mt-1">This person can't be checked in until a parent or guardian gives consent.{canOverrideConsent ? ' As a supervisor you can override this — it is recorded in the audit log.' : ' Ask a supervisor.'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(confirmModal.ticket?.quantity || 1) > 1 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Qty to redeem (max {confirmModal.remaining})</label>
|
||||
@@ -408,12 +495,23 @@ export default function TicketScanningPage() {
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={commitScan}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
|
||||
>
|
||||
Confirm Scan
|
||||
</button>
|
||||
{consentBlocked ? (
|
||||
canOverrideConsent && (
|
||||
<button
|
||||
onClick={() => commitScan(true)}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700"
|
||||
>
|
||||
Override and check in
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
onClick={() => commitScan()}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
|
||||
>
|
||||
Confirm Scan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 & 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 "Named attendees" 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'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. "Rooms", "Small groups" or "Buses". An event can have several.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ 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";
|
||||
|
||||
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||||
@@ -102,6 +104,12 @@ export default function AtTheDoorPage() {
|
||||
const [showOptionsModal, setShowOptionsModal] = useState(false);
|
||||
const [pendingUser, setPendingUser] = useState<any | null>(null);
|
||||
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
|
||||
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
|
||||
const [doorCollectGender, setDoorCollectGender] = useState(false);
|
||||
const [doorNaming, setDoorNaming] = useState(false);
|
||||
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
|
||||
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
|
||||
const [pendingEditReg, setPendingEditReg] = useState<any | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
@@ -155,6 +163,9 @@ 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([]);
|
||||
}
|
||||
@@ -224,6 +235,11 @@ export default function AtTheDoorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!pendingEditReg && doorYouthStart) {
|
||||
const missing = consentDetailsMissing(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries, doorYouthStart);
|
||||
if (missing) { setError(missing); return; }
|
||||
}
|
||||
|
||||
setConfirming(true);
|
||||
try {
|
||||
if (pendingEditReg) {
|
||||
@@ -266,6 +282,9 @@ export default function AtTheDoorPage() {
|
||||
options: opts,
|
||||
notificationPreference: pendingUser.notifPref,
|
||||
...(doorCoupon ? { couponCode: doorCoupon.code } : {}),
|
||||
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
|
||||
? { attendees: attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
|
||||
: {}),
|
||||
}
|
||||
});
|
||||
setShowOptionsModal(false);
|
||||
@@ -302,6 +321,9 @@ export default function AtTheDoorPage() {
|
||||
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
|
||||
setPendingEditReg(null);
|
||||
setDoorCoupon(null);
|
||||
setDoorNaming(false);
|
||||
setDoorEntries({});
|
||||
setDoorDelivery("payer");
|
||||
setShowNewAttendeeModal(false);
|
||||
setShowOptionsModal(true);
|
||||
};
|
||||
@@ -489,6 +511,13 @@ export default function AtTheDoorPage() {
|
||||
setCoupon={setDoorCoupon}
|
||||
couponForUserId={pendingEditReg ? pendingEditReg.userId : null}
|
||||
existingCode={(pendingEditReg?.couponRedemptions || [])[0]?.coupon?.code || null}
|
||||
names={doorAllowsNames && !pendingEditReg ? {
|
||||
enabled: doorNaming, onEnabledChange: setDoorNaming,
|
||||
entries: doorEntries, onEntriesChange: setDoorEntries,
|
||||
delivery: doorDelivery, onDeliveryChange: setDoorDelivery,
|
||||
consentEventStart: doorYouthStart || undefined,
|
||||
collectGender: doorCollectGender,
|
||||
} : null}
|
||||
/>
|
||||
<DonationModal
|
||||
open={showDonationModal}
|
||||
@@ -844,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(() => {
|
||||
@@ -975,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
|
||||
@@ -983,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>
|
||||
@@ -1003,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>
|
||||
))}
|
||||
@@ -1014,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}
|
||||
@@ -1173,7 +1225,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
|
||||
);
|
||||
}
|
||||
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode }: any) {
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, names }: any) {
|
||||
if (!open) return null;
|
||||
|
||||
const basket = Object.entries(quantities as Record<string, number>)
|
||||
@@ -1287,6 +1339,16 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
One or more items are below the quantity already issued as tickets. Please increase the selection.
|
||||
</div>
|
||||
)}
|
||||
{names && (
|
||||
<div className="mb-3 max-h-64 overflow-y-auto">
|
||||
<AttendeeNamesEditor
|
||||
options={safeOptions}
|
||||
qtyFor={(o: string, v: string | null) => quantities[v ? `${o}::${v}` : o] || 0}
|
||||
{...names}
|
||||
payerLabel="the person registering"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{existingCode ? (
|
||||
<div className="mb-3 text-sm text-green-700">Code <span className="font-mono font-semibold">{existingCode}</span> is applied to this registration.</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
|
||||
type ConsentRow = {
|
||||
attendeeId: string;
|
||||
name: string;
|
||||
dateOfBirth: string | null;
|
||||
minor: boolean;
|
||||
parentName: string | null;
|
||||
parentEmail: string | null;
|
||||
parentPhone: string | null;
|
||||
registrationStatus: string;
|
||||
bookedBy: { name: string; email: string };
|
||||
status: "not_required" | "missing" | "pending" | "given" | "declined";
|
||||
request: { id: string; status: string; textVersion: number; signedName: string | null; respondedAt: string | null; lastSentAt: string; remindersSent: number; photoConsent: boolean | null; outdated: boolean } | null;
|
||||
};
|
||||
type MedicalRow = {
|
||||
name: string; dateOfBirth: string | null; parentName: string | null; parentPhone: string | null; parentEmail: string | null;
|
||||
medical: { allergies: string; medication: string; conditions: string; medicalAid: string } | null;
|
||||
emergency: { name: string; phone: string; relationship: string } | null;
|
||||
photoConsent: boolean | null; purged: boolean;
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
|
||||
not_required: { label: "Not needed (18+)", cls: "bg-gray-100 text-gray-600" },
|
||||
missing: { label: "No request", cls: "bg-red-100 text-red-800" },
|
||||
pending: { label: "Pending", cls: "bg-amber-100 text-amber-800" },
|
||||
given: { label: "Given", cls: "bg-green-100 text-green-800" },
|
||||
declined: { label: "Declined", cls: "bg-red-100 text-red-800" },
|
||||
};
|
||||
|
||||
const esc = (s: unknown) => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c] as string));
|
||||
|
||||
export default function ConsentTrackerPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const canView = useMemo(() => ["admin", "supervisor", "staff"].includes(user?.role || ""), [user]);
|
||||
const isSupervisor = user?.role === "admin" || user?.role === "supervisor";
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(search.get("eventId") || "");
|
||||
const [data, setData] = useState<{ event: any; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
|
||||
const [filter, setFilter] = useState<"minors" | "all" | "waiting">("minors");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const youth = (evs || []).filter(e => e.requiresParentalConsent);
|
||||
setEvents(youth);
|
||||
if (!eventId && youth.length) setEventId(youth[youth.length - 1].id);
|
||||
})
|
||||
.catch(e => setError(e?.message || "Failed to load events"));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, canView]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await apiFetch(`/api/consent/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load consent status");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const all = data?.rows || [];
|
||||
if (filter === "all") return all;
|
||||
if (filter === "waiting") return all.filter(r => ["missing", "pending", "declined"].includes(r.status) || r.request?.outdated);
|
||||
return all.filter(r => r.minor);
|
||||
}, [data, filter]);
|
||||
|
||||
const outdatedCount = (data?.rows || []).filter(r => r.request?.outdated || (r.minor && r.status === "missing")).length;
|
||||
|
||||
const act = async (fn: () => Promise<{ message: string }>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fn();
|
||||
setInfo(res.message);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resendOne = (r: ConsentRow) => act(() => apiFetch(`/api/consent/requests/${r.request!.id}/resend`, { method: "POST", authToken: token! }));
|
||||
const resendAll = () => act(() => apiFetch(`/api/consent/events/${eventId}/resend-pending`, { method: "POST", authToken: token! }));
|
||||
const rerequest = () => act(() => apiFetch(`/api/consent/events/${eventId}/rerequest`, { method: "POST", authToken: token! }));
|
||||
|
||||
// Printable list for camp leaders. Opening it is recorded in the audit log.
|
||||
const printMedical = async () => {
|
||||
try {
|
||||
const res = await apiFetch<{ event: any; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) { setError("Allow pop-ups to print the medical list."); return; }
|
||||
const rowsHtml = res.rows.map(r => `
|
||||
<tr>
|
||||
<td><strong>${esc(r.name)}</strong><br/><small>${r.dateOfBirth ? esc(formatDate(new Date(r.dateOfBirth))) : ""}</small></td>
|
||||
<td>${r.purged ? "<em>Deleted after the event</em>" : `
|
||||
${r.medical?.allergies ? `<div><b>Allergies:</b> ${esc(r.medical.allergies)}</div>` : ""}
|
||||
${r.medical?.medication ? `<div><b>Medication:</b> ${esc(r.medical.medication)}</div>` : ""}
|
||||
${r.medical?.conditions ? `<div><b>Conditions:</b> ${esc(r.medical.conditions)}</div>` : ""}
|
||||
${r.medical?.medicalAid ? `<div><b>Medical aid:</b> ${esc(r.medical.medicalAid)}</div>` : ""}`}</td>
|
||||
<td>${r.emergency ? `${esc(r.emergency.name)}${r.emergency.relationship ? ` (${esc(r.emergency.relationship)})` : ""}<br/>${esc(r.emergency.phone)}` : ""}</td>
|
||||
<td>${esc(r.parentName)}<br/>${esc(r.parentPhone)}</td>
|
||||
<td>${r.photoConsent === null ? "" : r.photoConsent ? "Yes" : "No"}</td>
|
||||
</tr>`).join("");
|
||||
w.document.write(`<!doctype html><html><head><meta charset="utf-8"/><title>Medical & emergency list — ${esc(res.event.title)}</title>
|
||||
<style>body{font-family:system-ui,sans-serif;margin:24px;font-size:12px}h1{font-size:18px;margin:0 0 4px}p{color:#555;margin:0 0 12px}
|
||||
table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccc;padding:6px;vertical-align:top;text-align:left}th{background:#f3f4f6}
|
||||
.conf{color:#b91c1c;font-weight:600}</style></head><body>
|
||||
<h1>Medical & emergency list — ${esc(res.event.title)}</h1>
|
||||
<p class="conf">Confidential — for event leaders only. Do not leave unattended; destroy after the event.</p>
|
||||
<table><thead><tr><th>Attendee</th><th>Medical</th><th>Emergency contact</th><th>Parent / guardian</th><th>Photos</th></tr></thead>
|
||||
<tbody>${rowsHtml || '<tr><td colspan="5">No consent given yet.</td></tr>'}</tbody></table>
|
||||
<script>window.onload=()=>window.print()</script></body></html>`);
|
||||
w.document.close();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load the medical list");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Parental consent</h1>
|
||||
<p className="text-sm text-gray-500">Youth events: who still needs a parent's consent before check-in</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need staff access to view consent status.</div>}
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
|
||||
|
||||
{canView && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Youth event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No youth events</option>}
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Show</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm" value={filter} onChange={e => setFilter(e.target.value as typeof filter)}>
|
||||
<option value="minors">Under 18s</option>
|
||||
<option value="waiting">Still to sort out</option>
|
||||
<option value="all">Everyone</option>
|
||||
</select>
|
||||
</div>
|
||||
{isSupervisor && eventId && (
|
||||
<div className="flex flex-wrap gap-2 ml-auto">
|
||||
<button onClick={resendAll} disabled={busy || !(data?.counts?.pending)} className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50"><Send className="w-4 h-4" /> Resend to all pending</button>
|
||||
<button onClick={rerequest} disabled={busy || outdatedCount === 0} title="For consent given on an older version of the text, or under 18s who never got a request" className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50"><RefreshCw className="w-4 h-4" /> Re-request consent ({outdatedCount})</button>
|
||||
<button onClick={printMedical} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-brand-600 text-white text-sm hover:bg-brand-700 disabled:opacity-50"><HeartPulse className="w-4 h-4" /> <Printer className="w-4 h-4" /> Medical & emergency list</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="flex flex-wrap gap-2 mb-3 text-xs">
|
||||
{(["given", "pending", "declined", "missing"] as const).map(s => (
|
||||
<span key={s} className={`px-2 py-1 rounded-full font-medium ${STATUS_STYLE[s].cls}`}>{STATUS_STYLE[s].label}: {data.counts?.[s] || 0}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Attendee</TableHead>
|
||||
<TableHead>Parent / guardian</TableHead>
|
||||
<TableHead>Consent</TableHead>
|
||||
<TableHead>Booked by</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map(r => {
|
||||
const st = STATUS_STYLE[r.status];
|
||||
return (
|
||||
<TableRow key={r.attendeeId}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{r.name}</div>
|
||||
<div className="text-xs text-gray-500">{r.dateOfBirth ? formatDate(new Date(r.dateOfBirth)) : "No date of birth"}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{r.parentName ? <>
|
||||
<div>{r.parentName}</div>
|
||||
<div className="text-xs text-gray-500">{[r.parentEmail, r.parentPhone].filter(Boolean).join(" · ")}</div>
|
||||
</> : <span className="text-gray-400">—</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${st.cls}`}>{st.label}</span>
|
||||
{r.request?.outdated && <div className="text-xs text-amber-700 mt-1">Given on an older version of the text</div>}
|
||||
{r.request?.respondedAt && <div className="text-xs text-gray-500 mt-1">{r.request.signedName} · {formatDate(new Date(r.request.respondedAt))}</div>}
|
||||
{r.status === "pending" && r.request && <div className="text-xs text-gray-500 mt-1">Sent {formatDate(new Date(r.request.lastSentAt))}{r.request.remindersSent ? ` · ${r.request.remindersSent} reminder(s)` : ""}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{r.bookedBy?.name}<div className="text-xs text-gray-500">{r.registrationStatus}</div></TableCell>
|
||||
<TableCell className="text-right">
|
||||
{r.status === "pending" && r.request && (
|
||||
<button onClick={() => resendOne(r)} disabled={busy} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Resend</button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{!busy && rows.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-sm text-gray-500 py-8">{eventId ? "No attendees to show." : "Pick a youth event."}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Reminders go out automatically 2 days after a request, then every 3 days until the event. Medical details are deleted a set number of days after the event (Site Settings → Legal).</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -769,7 +769,12 @@ interface EventDraft {
|
||||
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
|
||||
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
|
||||
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
|
||||
location: string;
|
||||
location: string; allowNamedAttendees: boolean;
|
||||
requiresParentalConsent: boolean; consentText: string;
|
||||
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
|
||||
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
|
||||
allowTicketTransfer: boolean; collectGender: boolean;
|
||||
allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean;
|
||||
}
|
||||
|
||||
const blankDraft = (location = ""): EventDraft => ({
|
||||
@@ -777,7 +782,12 @@ const blankDraft = (location = ""): EventDraft => ({
|
||||
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
|
||||
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
|
||||
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
|
||||
location,
|
||||
location, allowNamedAttendees: false,
|
||||
requiresParentalConsent: false, consentText: "",
|
||||
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
|
||||
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
|
||||
allowTicketTransfer: true, collectGender: false,
|
||||
allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true,
|
||||
});
|
||||
|
||||
const blankOptions = (): OptionDraft[] => [
|
||||
@@ -812,6 +822,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
|
||||
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
|
||||
requiresRegistration: ev.requiresRegistration !== false,
|
||||
allowNamedAttendees: !!ev.allowNamedAttendees,
|
||||
requiresParentalConsent: !!ev.requiresParentalConsent, consentText: ev.consentText || "",
|
||||
consentCollectMedical: !!ev.consentCollectMedical, consentCollectEmergency: !!ev.consentCollectEmergency, consentCollectPhoto: !!ev.consentCollectPhoto,
|
||||
waitlistEnabled: !!ev.waitlistEnabled, waitlistOfferHours: String(ev.waitlistOfferHours ?? 24), waitlistPartialOffers: ev.waitlistPartialOffers !== false,
|
||||
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 || ""));
|
||||
@@ -1085,6 +1102,20 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth,
|
||||
redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined,
|
||||
requiresRegistration: draft.requiresRegistration,
|
||||
allowNamedAttendees: draft.allowNamedAttendees || draft.requiresParentalConsent,
|
||||
requiresParentalConsent: draft.requiresParentalConsent,
|
||||
consentText: draft.consentText,
|
||||
consentCollectMedical: draft.consentCollectMedical,
|
||||
consentCollectEmergency: draft.consentCollectEmergency,
|
||||
consentCollectPhoto: draft.consentCollectPhoto,
|
||||
waitlistEnabled: draft.waitlistEnabled,
|
||||
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,
|
||||
contactPhone: draft.contactPhone || undefined,
|
||||
contactEmail: draft.contactEmail || undefined,
|
||||
@@ -1339,6 +1370,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<input type="checkbox" className="mt-0.5" checked={!draft.requiresAuth} onChange={e => upd({ requiresAuth: !e.target.checked })} />
|
||||
<span><strong>Allow guest registration</strong> — attendees can register without creating an account</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.allowNamedAttendees} onChange={e => upd({ allowNamedAttendees: e.target.checked })} />
|
||||
<span><strong>Named attendees (group tickets)</strong> — someone registering several people can give each a name, so each gets their own ticket and attendee form</span>
|
||||
</label>
|
||||
{(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's gender</strong> — needed to split rooms or cabins by gender (Rooms & 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 })} />
|
||||
@@ -1346,6 +1387,78 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Waitlist</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.waitlistEnabled} onChange={e => upd({ waitlistEnabled: e.target.checked })} />
|
||||
<span><strong>Waitlist for sold-out tickets</strong> — people can join a queue; when places free up, the next person is offered them and has a set time to claim</span>
|
||||
</label>
|
||||
{draft.waitlistEnabled && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
Hold offered places for
|
||||
<input type="number" min="1" max="168" className="border rounded px-2 py-1 text-sm w-20" value={draft.waitlistOfferHours} onChange={e => upd({ waitlistOfferHours: e.target.value })} />
|
||||
hours
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.waitlistPartialOffers} onChange={e => upd({ waitlistPartialOffers: e.target.checked })} />
|
||||
<span>When fewer places free up than someone asked for, offer them what's free (otherwise skip to the next person who fits)</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">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 "Sponsor a spot" button on the event page lets people pay for spots (or any amount) for those who can'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 ("12 of 20 spots sponsored")</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'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">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.requiresParentalConsent} onChange={e => upd({ requiresParentalConsent: e.target.checked, ...(e.target.checked ? { allowNamedAttendees: true } : {}) })} />
|
||||
<span><strong>Requires parental consent</strong> — every attendee gives a date of birth; for anyone under 18, a parent or guardian is asked by email and WhatsApp to give consent before they can be checked in</span>
|
||||
</label>
|
||||
{draft.requiresParentalConsent && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Consent text (shown to the parent)</label>
|
||||
<textarea className="w-full border rounded px-2 py-1.5 text-sm" rows={6} value={draft.consentText} onChange={e => upd({ consentText: e.target.value })}
|
||||
placeholder="e.g. I give permission for my child to take part in all activities, including transport, and for leaders to arrange medical treatment in an emergency." />
|
||||
{mode === "edit" && <p className="text-[11px] text-gray-500 mt-1">Changing this starts a new version. Parents who already agreed keep their record; use "Re-request consent" on the Parental consent page to ask them again.</p>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Also ask the parent for:</div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.consentCollectMedical} onChange={e => upd({ consentCollectMedical: e.target.checked })} /> Medical info (allergies, medication, conditions, medical aid)</label>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.consentCollectEmergency} onChange={e => upd({ consentCollectEmergency: e.target.checked })} /> An emergency contact</label>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.consentCollectPhoto} onChange={e => upd({ consentCollectPhoto: e.target.checked })} /> Photo consent (yes/no)</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { UserPlus, CreditCard } from "lucide-react";
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
@@ -218,6 +219,14 @@ export default function ManualRegistrationPage() {
|
||||
|
||||
// Discount code, checked against the picked account (or a walk-in when none is picked).
|
||||
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
|
||||
// Named attendees (group tickets), when the event takes them.
|
||||
const [namingOthers, setNamingOthers] = useState(false);
|
||||
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
|
||||
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
|
||||
const manualQtyFor = (optionId: string, variantId: string | null) => quantities[variantId ? `${optionId}::${variantId}` : optionId] || 0;
|
||||
const selectedEv = events.find(ev => ev.id === selectedEventId);
|
||||
const selectedIsYouth = !!selectedEv?.requiresParentalConsent;
|
||||
const selectedEventAllowsNames = !!selectedEv?.allowNamedAttendees || selectedIsYouth;
|
||||
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
|
||||
|
||||
const selectUser = (u: any) => {
|
||||
@@ -300,6 +309,10 @@ export default function ManualRegistrationPage() {
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
|
||||
});
|
||||
if (opts.length === 0) { setError("Please select at least one ticket option."); return; }
|
||||
if (selectedIsYouth) {
|
||||
const missing = consentDetailsMissing(options, manualQtyFor, attendeeEntries, selectedEv.startDate);
|
||||
if (missing) { setError(missing); return; }
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
@@ -315,6 +328,9 @@ export default function ManualRegistrationPage() {
|
||||
user: guest,
|
||||
notificationPreference: resolvedPref,
|
||||
...(coupon ? { couponCode: coupon.code } : {}),
|
||||
...(selectedEventAllowsNames && (namingOthers || selectedIsYouth) && attendeesPayload(options, manualQtyFor, attendeeEntries).length > 0
|
||||
? { attendees: attendeesPayload(options, manualQtyFor, attendeeEntries), ticketDelivery: delivery }
|
||||
: {}),
|
||||
}
|
||||
});
|
||||
setMessage(res?.couponError
|
||||
@@ -336,6 +352,9 @@ export default function ManualRegistrationPage() {
|
||||
setDropdownOpen(false);
|
||||
setCoupon(null);
|
||||
setPickedUserId(null);
|
||||
setNamingOthers(false);
|
||||
setAttendeeEntries({});
|
||||
setDelivery("payer");
|
||||
setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0])));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create manual registration");
|
||||
@@ -592,6 +611,24 @@ export default function ManualRegistrationPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedEventAllowsNames && (
|
||||
<div className="mt-6">
|
||||
<AttendeeNamesEditor
|
||||
options={options}
|
||||
qtyFor={manualQtyFor}
|
||||
enabled={namingOthers}
|
||||
onEnabledChange={setNamingOthers}
|
||||
entries={attendeeEntries}
|
||||
onEntriesChange={setAttendeeEntries}
|
||||
delivery={delivery}
|
||||
onDeliveryChange={setDelivery}
|
||||
payerLabel="the person registering"
|
||||
consentEventStart={selectedIsYouth ? selectedEv.startDate : undefined}
|
||||
collectGender={!!selectedEv?.collectGender}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEventId && totalDue > 0 && (
|
||||
<div className="mt-6 max-w-md">
|
||||
<CouponCodeField
|
||||
|
||||
@@ -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,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -24,6 +24,10 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/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 },
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
|
||||
@@ -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's message: “{selectedDonation.sponsorMessage}”</div>
|
||||
)}
|
||||
|
||||
{selectedDonation && (
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
position: number;
|
||||
quantity: number;
|
||||
offeredQuantity: number | null;
|
||||
offerExpiresAt: string | null;
|
||||
status: "waiting" | "offered" | "claimed" | "expired" | "removed" | "left";
|
||||
createdAt: string;
|
||||
eventOption: { name: string };
|
||||
variant: { name: string } | null;
|
||||
user: { name: string; email: string; phoneNumber: string | null };
|
||||
};
|
||||
|
||||
const STATUS: Record<Entry["status"], { label: string; cls: string }> = {
|
||||
waiting: { label: "Waiting", cls: "bg-gray-100 text-gray-700" },
|
||||
offered: { label: "Offered", cls: "bg-amber-100 text-amber-800" },
|
||||
claimed: { label: "Claimed", cls: "bg-green-100 text-green-800" },
|
||||
expired: { label: "Offer expired", cls: "bg-red-100 text-red-800" },
|
||||
removed: { label: "Removed", cls: "bg-gray-100 text-gray-500" },
|
||||
left: { label: "Left", cls: "bg-gray-100 text-gray-500" },
|
||||
};
|
||||
|
||||
export default function WaitlistPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const canView = useMemo(() => ["admin", "supervisor", "staff"].includes(user?.role || ""), [user]);
|
||||
const canManage = user?.role === "admin" || user?.role === "supervisor";
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [eventId, setEventId] = useState(search.get("eventId") || "");
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all", { authToken: token })
|
||||
.then(evs => {
|
||||
const withList = (evs || []).filter(e => e.waitlistEnabled);
|
||||
setEvents(withList);
|
||||
if (!eventId && withList.length) setEventId(withList[0].id);
|
||||
})
|
||||
.catch(e => setError(e?.message || "Failed to load events"));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, canView]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setEntries(await apiFetch<Entry[]>(`/api/waitlist/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load the waitlist");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const act = async (path: string, body?: any) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(path, { method: "POST", authToken: token!, body });
|
||||
if (res?.message) setInfo(res.message);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const waitingCount = entries.filter(e => e.status === "waiting").length;
|
||||
const selected = events.find(e => e.id === eventId);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<ListChecks className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Waitlist</h1>
|
||||
<p className="text-sm text-gray-500">People waiting for sold-out tickets</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need staff access to view the waitlist.</div>}
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
|
||||
|
||||
{canView && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No events with a waitlist</option>}
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{selected && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{waitingCount} waiting · offers are held for {selected.waitlistOfferHours} hours · {selected.waitlistPartialOffers ? "partial offers on" : "only whole requests offered"}.
|
||||
Turn the waitlist on or off in the event's Visibility step.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Person</TableHead>
|
||||
<TableHead>Ticket</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
{canManage && <TableHead className="text-right">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((e, i) => {
|
||||
const st = STATUS[e.status];
|
||||
const active = e.status === "waiting" || e.status === "offered";
|
||||
return (
|
||||
<TableRow key={e.id} className={active ? "" : "opacity-60"}>
|
||||
<TableCell className="text-sm text-gray-500">{i + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm font-medium">{e.user.name}</div>
|
||||
<div className="text-xs text-gray-500">{[e.user.email, e.user.phoneNumber].filter(Boolean).join(" · ")}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</TableCell>
|
||||
<TableCell className="text-sm">{e.quantity}{e.status === "offered" && e.offeredQuantity !== e.quantity ? ` (${e.offeredQuantity} offered)` : ""}</TableCell>
|
||||
<TableCell className="text-xs text-gray-600">{formatDate(new Date(e.createdAt))}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${st.cls}`}>{st.label}</span>
|
||||
{e.status === "offered" && e.offerExpiresAt && <div className="text-xs text-gray-500 mt-1">until {new Date(e.offerExpiresAt).toLocaleString()}</div>}
|
||||
</TableCell>
|
||||
{canManage && (
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
{e.status === "waiting" && (
|
||||
<>
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "up" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move up"><ArrowUp className="w-3.5 h-3.5" /></button>
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "down" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move down"><ArrowDown className="w-3.5 h-3.5" /></button>
|
||||
</>
|
||||
)}
|
||||
{(e.status === "waiting" || e.status === "expired") && (
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/offer`)} className="px-2 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 mr-1" title="Offer now, even if no place has freed up">Offer place</button>
|
||||
)}
|
||||
{active && (
|
||||
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/remove`)} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Remove</button>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{!busy && entries.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-sm text-gray-500 py-8">{eventId ? "Nobody is on the waitlist." : "Pick an event."}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">When places free up (a cancellation, a smaller booking, or a higher stock limit), the next people are offered them automatically. "Offer place" sends an offer straight away, even if it means going over the stock limit.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -8,6 +8,9 @@ import { formatPaymentMethod } from "@/lib/paymentMethod";
|
||||
import { QrImage } from "@/components/shared/QrImage";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
|
||||
import { MyWaitlists } from "@/components/shared/MyWaitlists";
|
||||
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
|
||||
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
|
||||
|
||||
// Helper formatters
|
||||
@@ -381,7 +384,7 @@ export default function UserDashboardPage() {
|
||||
${dateStr ? `<div class="date">${dateStr}</div>` : ''}
|
||||
</div>
|
||||
<div class="type">${type}</div>
|
||||
<div class="qty">Qty: <strong>${qty}</strong></div>
|
||||
${t.attendee?.name ? `<div class="qty">Name: <strong>${t.attendee.name}</strong></div>` : `<div class="qty">Qty: <strong>${qty}</strong></div>`}
|
||||
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
|
||||
<div class="meta">Ticket ID: ${t.id}</div>
|
||||
${buyerName ? `<div class="meta">Purchased by: ${buyerName}</div>` : ''}
|
||||
@@ -532,6 +535,19 @@ 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;
|
||||
try {
|
||||
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
setRegistrations(myRegs);
|
||||
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
|
||||
setTickets(myTicks);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Cancel registration state
|
||||
const [cancelLoading, setCancelLoading] = useState(false);
|
||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
||||
@@ -859,6 +875,12 @@ export default function UserDashboardPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{token && (
|
||||
<div className="mb-6">
|
||||
<MyWaitlists token={token} onMessage={setInfo} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
@@ -971,8 +993,16 @@ export default function UserDashboardPage() {
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">{type}</div>
|
||||
<div className="text-xs font-medium text-blue-700 bg-blue-50 rounded px-2 py-0.5 inline-block">
|
||||
Qty: {t.quantity || 1}
|
||||
{t.attendee?.name ? <>For: {t.attendee.name}</> : <>Qty: {t.quantity || 1}</>}
|
||||
</div>
|
||||
{(t.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>
|
||||
)}
|
||||
<div className="flex justify-center"><QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" /></div>
|
||||
<div className="text-center text-xs text-gray-500">#{t.id.slice(0, 8)}</div>
|
||||
<div className="flex gap-2 pt-1 flex-wrap">
|
||||
@@ -981,6 +1011,9 @@ 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>
|
||||
{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>
|
||||
);
|
||||
@@ -990,6 +1023,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()}>
|
||||
@@ -1202,6 +1245,16 @@ export default function UserDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{token && (
|
||||
<AttendeesPanel
|
||||
registration={activeReg}
|
||||
token={token}
|
||||
canEdit={canModifyActive}
|
||||
hasForm={!!(activeReg.event?.form?.fields || []).length}
|
||||
onSaved={reloadAfterAttendees}
|
||||
onMessage={setInfo}
|
||||
/>
|
||||
)}
|
||||
{activeBill && activeBill.payments.length > 0 && (
|
||||
<div>
|
||||
<div className="font-medium mb-1">Payments</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import ClientActions from "@/app/events/[id]/ClientActions";
|
||||
import { ContactButton } from "@/components/events/ContactButton";
|
||||
import { LocationMap } from "@/components/events/LocationMap";
|
||||
import { JoinWaitlistButton, soldOutLines } from "@/components/events/JoinWaitlistButton";
|
||||
import { SponsorSpot } from "@/components/events/SponsorSpot";
|
||||
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
@@ -40,6 +42,8 @@ type Event = {
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
location?: string | null;
|
||||
waitlistEnabled?: boolean;
|
||||
allowSponsorships?: boolean;
|
||||
};
|
||||
|
||||
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
@@ -93,6 +97,14 @@ function RegisterCta({ event }: { event: Event }) {
|
||||
);
|
||||
}
|
||||
if (eventSoldOut) {
|
||||
if (event.waitlistEnabled) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-center text-sm font-medium text-red-700">Sold out</div>
|
||||
<JoinWaitlistButton eventId={event.id} lines={soldOutLines(event.eventOptions || [])} guestsAllowed={event.requiresAuth === false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button disabled className="w-full bg-red-50 text-red-700 border border-red-200 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium">
|
||||
Sold Out
|
||||
@@ -212,6 +224,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>
|
||||
|
||||
@@ -225,6 +238,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>
|
||||
|
||||
@@ -15,6 +15,8 @@ function FormsContent() {
|
||||
const router = useRouter();
|
||||
const { token } = useAuth();
|
||||
const registrationId = search.get("registrationId");
|
||||
// Set when a named attendee opens the link sent to them — show only their form.
|
||||
const onlyAttendeeId = search.get("attendeeId");
|
||||
|
||||
const [registration, setRegistration] = useState<any | null>(null);
|
||||
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
@@ -22,8 +24,8 @@ function FormsContent() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
|
||||
// Local entry state for new responses
|
||||
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
|
||||
// Local entry state for new responses, keyed by form block (see `blocks` below)
|
||||
const [formsData, setFormsData] = useState<Record<string, Record<string, string>>>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -65,7 +67,28 @@ function FormsContent() {
|
||||
}
|
||||
}, [registration]);
|
||||
|
||||
const remaining = Math.max(0, requiredCount - submittedCount);
|
||||
// One form block per place still to fill: each named attendee (group tickets) by name,
|
||||
// then numbered blocks for unnamed places. Unnamed blocks keep their old numeric keys,
|
||||
// so drafts saved before attendees existed still prefill.
|
||||
type FormBlock = { key: string; label: string; attendeeId?: string };
|
||||
const blocks = useMemo((): FormBlock[] => {
|
||||
if (!registration) return [];
|
||||
const responses = registration.formResponses || [];
|
||||
const answered = new Set(responses.map((r: any) => r.attendeeId).filter(Boolean));
|
||||
const attendees = (registration.attendees || []) as any[];
|
||||
const named = attendees
|
||||
.filter(a => !answered.has(a.id))
|
||||
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id as string }));
|
||||
const unnamedPlaces = Math.max(0, requiredCount - attendees.length);
|
||||
const unnamedAnswered = responses.filter((r: any) => !r.attendeeId).length;
|
||||
const unnamed = Array.from({ length: Math.max(0, unnamedPlaces - unnamedAnswered) }, (_, i) => ({
|
||||
key: String(i), label: `Attendee ${unnamedAnswered + i + 1}`,
|
||||
}));
|
||||
const all: FormBlock[] = [...named, ...unnamed];
|
||||
return onlyAttendeeId ? all.filter(b => b.attendeeId === onlyAttendeeId) : all;
|
||||
}, [registration, requiredCount, onlyAttendeeId]);
|
||||
const remaining = blocks.length;
|
||||
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a: any) => a.id === id)?.name;
|
||||
|
||||
// Determine if all required fields are filled for each attendee form to be submitted
|
||||
const canSubmit = useMemo(() => {
|
||||
@@ -73,8 +96,8 @@ function FormsContent() {
|
||||
const reqFields = (eventForm.fields || [])
|
||||
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
|
||||
.map(f => f.id);
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
const data = formsData[i] || {};
|
||||
for (const b of blocks) {
|
||||
const data = formsData[b.key] || {};
|
||||
for (const fid of reqFields) {
|
||||
const v = data[fid];
|
||||
if (v === undefined || v === null || String(v).trim() === '') {
|
||||
@@ -83,16 +106,13 @@ function FormsContent() {
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}, [eventForm, formsData, remaining]);
|
||||
}, [eventForm, formsData, remaining, blocks]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!registrationId) return;
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
const payload: any[] = [];
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
payload.push({ answers: formsData[i] || {} });
|
||||
}
|
||||
const payload = blocks.map(b => ({ ...(b.attendeeId ? { attendeeId: b.attendeeId } : {}), answers: formsData[b.key] || {} }));
|
||||
if (payload.length === 0) {
|
||||
// Nothing left to submit – still go to success page to continue the flow
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
@@ -191,7 +211,7 @@ function FormsContent() {
|
||||
<ul className="space-y-2">
|
||||
{(registration.formResponses || []).map((resp: any, idx: number) => (
|
||||
<li key={resp.id} className="bg-white border rounded p-2">
|
||||
<div className="font-medium text-sm mb-1">Attendee {idx + 1}</div>
|
||||
<div className="font-medium text-sm mb-1">{attendeeName(resp.attendeeId) || `Attendee ${idx + 1}`}</div>
|
||||
{(resp.answers || []).length === 0 ? (
|
||||
<div className="text-xs text-gray-600">No answers recorded.</div>
|
||||
) : (
|
||||
@@ -214,9 +234,9 @@ function FormsContent() {
|
||||
<div className="border rounded p-3 bg-gray-50">
|
||||
<div className="text-sm font-medium mb-2">Fill remaining attendee details ({remaining})</div>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: remaining }, (_, idx) => (
|
||||
{blocks.map(({ key: idx, label }) => (
|
||||
<div key={idx} className="bg-white border rounded p-3">
|
||||
<div className="font-medium mb-2">Attendee {submittedCount + idx + 1}</div>
|
||||
<div className="font-medium mb-2">{label}</div>
|
||||
{eventForm.fields.map((f) => (
|
||||
<div key={f.id} className="mb-2">
|
||||
{f.type === 'statement' ? (
|
||||
@@ -252,7 +272,9 @@ function FormsContent() {
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
|
||||
{!onlyAttendeeId && (
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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" }],
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
|
||||
|
||||
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
|
||||
function lowStockThreshold(stockLimit: number): number {
|
||||
@@ -40,8 +42,14 @@ type Event = {
|
||||
eventOptions?: EventOption[];
|
||||
form?: { isRequired: boolean; fields?: any[] } | null;
|
||||
requiresAuth?: boolean;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
collectGender?: boolean;
|
||||
waitlistEnabled?: boolean;
|
||||
};
|
||||
|
||||
type WaitlistOffer = { eventOptionId: string; variantId: string | null; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
|
||||
|
||||
// Key format: "optionId" for no-variant options, "optionId:variantId" for variant options
|
||||
type QtyMap = Record<string, number>;
|
||||
|
||||
@@ -125,10 +133,12 @@ function OptionCard({
|
||||
opt,
|
||||
quantities,
|
||||
onQtyChange,
|
||||
waitlist,
|
||||
}: {
|
||||
opt: EventOption;
|
||||
quantities: QtyMap;
|
||||
onQtyChange: (key: string, val: number) => void;
|
||||
waitlist?: { eventId: string; guestsAllowed: boolean } | null;
|
||||
}) {
|
||||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||||
const isSoldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
|
||||
@@ -152,7 +162,13 @@ function OptionCard({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSoldOut ? (
|
||||
<span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>
|
||||
<>
|
||||
<span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>
|
||||
{waitlist && (
|
||||
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
|
||||
lines={variants.map(v => ({ eventOptionId: opt.id, variantId: v.id, label: `${opt.name} (${v.name})` }))} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
|
||||
)}
|
||||
@@ -176,6 +192,10 @@ function OptionCard({
|
||||
<span className="text-sm">{variant.name}</span>
|
||||
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}</span>
|
||||
<StockBadge stockLimit={variant.stockLimit} availableCount={variant.availableCount} />
|
||||
{waitlist && variantSoldOut && !isSoldOut && (
|
||||
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
|
||||
lines={[{ eventOptionId: opt.id, variantId: variant.id, label: `${opt.name} (${variant.name})` }]} />
|
||||
)}
|
||||
</div>
|
||||
<QtyControl
|
||||
value={qty}
|
||||
@@ -216,6 +236,9 @@ function OptionCard({
|
||||
<span>R{price.toFixed(2)}</span>
|
||||
)}
|
||||
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
|
||||
{waitlist && isSoldOut && (
|
||||
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed} lines={[{ eventOptionId: opt.id, label: opt.name }]} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<QtyControl
|
||||
@@ -228,9 +251,41 @@ function OptionCard({
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterForm({ event }: { event: Event }) {
|
||||
export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
const { token, user } = useAuth();
|
||||
const eventId = event.id;
|
||||
const eventId = baseEvent.id;
|
||||
|
||||
// A waitlist claim link (?waitlist=<token>): the places held for this person are
|
||||
// added back to what's available, so they can choose them.
|
||||
const [waitlistToken, setWaitlistToken] = useState<string | null>(null);
|
||||
const [offer, setOffer] = useState<WaitlistOffer | null>(null);
|
||||
const [offerError, setOfferError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const t = new URLSearchParams(window.location.search).get("waitlist");
|
||||
if (!t) return;
|
||||
apiFetch<WaitlistOffer & { status: string }>(`/api/waitlist/claim/${encodeURIComponent(t)}`)
|
||||
.then(res => {
|
||||
if (res.status !== "offered") { setOfferError("This waitlist offer has expired."); return; }
|
||||
setWaitlistToken(t);
|
||||
setOffer(res);
|
||||
setQuantities(q => ({ ...q, [res.variantId ? `${res.eventOptionId}:${res.variantId}` : res.eventOptionId]: res.quantity }));
|
||||
})
|
||||
.catch((e: any) => setOfferError(e?.message || "This waitlist link is not valid."));
|
||||
}, []);
|
||||
const event = useMemo<Event>(() => {
|
||||
if (!offer) return baseEvent;
|
||||
return {
|
||||
...baseEvent,
|
||||
eventOptions: (baseEvent.eventOptions || []).map(o => {
|
||||
if (o.id !== offer.eventOptionId) return o;
|
||||
return {
|
||||
...o,
|
||||
availableCount: o.availableCount !== undefined ? o.availableCount + offer.quantity : o.availableCount,
|
||||
variants: (o.variants || []).map(v => v.id === offer.variantId && v.availableCount !== undefined ? { ...v, availableCount: v.availableCount + offer.quantity } : v),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, [baseEvent, offer]);
|
||||
|
||||
const [quantities, setQuantities] = useState<QtyMap>(() => {
|
||||
const initial: QtyMap = {};
|
||||
@@ -248,6 +303,14 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
const [creatingCheckout, setCreatingCheckout] = useState(false);
|
||||
const [creatingRegistration, setCreatingRegistration] = useState(false);
|
||||
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [namingOthers, setNamingOthers] = useState(false);
|
||||
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
|
||||
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
|
||||
const qtyFor = (optionId: string, variantId: string | null) => quantities[variantId ? `${optionId}:${variantId}` : optionId] || 0;
|
||||
const isYouthEvent = !!event.requiresParentalConsent;
|
||||
const namedAttendees = () => ((event.allowNamedAttendees && namingOthers) || isYouthEvent
|
||||
? attendeesPayload(event.eventOptions || [], qtyFor, attendeeEntries)
|
||||
: []);
|
||||
|
||||
// Guest details (for unauthenticated free-event registration)
|
||||
const [guestName, setGuestName] = useState("");
|
||||
@@ -318,6 +381,28 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
setError("Select at least one ticket.");
|
||||
return;
|
||||
}
|
||||
if (isYouthEvent) {
|
||||
const missing = consentDetailsMissing(event.eventOptions || [], qtyFor, attendeeEntries, event.startDate);
|
||||
if (missing) { setError(missing); return; }
|
||||
}
|
||||
|
||||
// Claim link without logging in: the link identifies the person.
|
||||
if (waitlistToken && !isLoggedIn) {
|
||||
try {
|
||||
setError(null);
|
||||
setCreatingRegistration(true);
|
||||
const res = await apiFetch<{ id: string; totalDueComputed?: number }>("/api/registrations", {
|
||||
method: "POST",
|
||||
body: { eventId, options: items, waitlistToken, ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
|
||||
});
|
||||
if (res?.id) window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((res.totalDueComputed ?? payable).toFixed(2))}`;
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Registration failed");
|
||||
} finally {
|
||||
setCreatingRegistration(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
if (!token) {
|
||||
@@ -330,7 +415,13 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
setCreatingRegistration(true);
|
||||
const res = await apiFetch<{ id: string; totalDueComputed?: number; couponError?: string }>("/api/registrations", {
|
||||
method: "POST",
|
||||
body: { eventId, options: items, ...(coupon ? { couponCode: coupon.code } : {}) },
|
||||
body: {
|
||||
eventId,
|
||||
options: items,
|
||||
...(coupon ? { couponCode: coupon.code } : {}),
|
||||
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
|
||||
...(waitlistToken ? { waitlistToken } : {}),
|
||||
},
|
||||
authToken: token,
|
||||
});
|
||||
if (res?.id) {
|
||||
@@ -370,7 +461,10 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
setCreatingRegistration(true);
|
||||
const res = await apiFetch<{ id: string }>("/api/registrations", {
|
||||
method: "POST",
|
||||
body: { eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined },
|
||||
body: {
|
||||
eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined,
|
||||
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
|
||||
},
|
||||
});
|
||||
if (res?.id) {
|
||||
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
|
||||
@@ -413,13 +507,22 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold mb-4">Register for {event.title}</h1>
|
||||
|
||||
{eventSoldOut && (
|
||||
{offer && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-green-50 border-green-200 text-sm text-green-900">
|
||||
<strong>Your waitlist place:</strong> {offer.quantity} × {offer.optionName}{offer.variantName ? ` (${offer.variantName})` : ""} — held for you until {new Date(offer.expiresAt).toLocaleString()}.
|
||||
</div>
|
||||
)}
|
||||
{offerError && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-amber-50 border-amber-200 text-sm text-amber-900">{offerError}</div>
|
||||
)}
|
||||
|
||||
{eventSoldOut && !event.waitlistEnabled && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-red-50 border-red-200 text-sm text-red-800 font-medium">
|
||||
This event is sold out. Registration is no longer available.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoggedIn && !isGuestEligible && (
|
||||
{!isLoggedIn && !isGuestEligible && !waitlistToken && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-amber-50 border-amber-200 text-sm text-amber-800">
|
||||
You need to <a href={`/login?redirect=/register/${eventId}`} className="font-semibold underline">log in</a> or <a href={`/register?redirect=/register/${eventId}`} className="font-semibold underline">create an account</a> to register for this event.
|
||||
</div>
|
||||
@@ -466,6 +569,7 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
opt={opt}
|
||||
quantities={quantities}
|
||||
onQtyChange={handleQtyChange}
|
||||
waitlist={event.waitlistEnabled ? { eventId, guestsAllowed: event.requiresAuth === false } : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -488,6 +592,23 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
);
|
||||
})()}
|
||||
|
||||
{(event.allowNamedAttendees || isYouthEvent) && !eventSoldOut && (isLoggedIn || showGuestForm) && (
|
||||
<div className="mt-6">
|
||||
<AttendeeNamesEditor
|
||||
options={event.eventOptions || []}
|
||||
qtyFor={qtyFor}
|
||||
enabled={namingOthers}
|
||||
onEnabledChange={setNamingOthers}
|
||||
entries={attendeeEntries}
|
||||
onEntriesChange={setAttendeeEntries}
|
||||
delivery={delivery}
|
||||
onDeliveryChange={setDelivery}
|
||||
consentEventStart={isYouthEvent ? event.startDate : undefined}
|
||||
collectGender={!!event.collectGender}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn && total > 0 && !eventSoldOut && (
|
||||
<div className="mt-6">
|
||||
<CouponCodeField eventId={eventId} basket={basket} token={token} applied={coupon} onChange={setCoupon} />
|
||||
@@ -512,7 +633,7 @@ export default function RegisterForm({ event }: { event: Event }) {
|
||||
) : (
|
||||
<button
|
||||
onClick={submitRegistration}
|
||||
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible)}
|
||||
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible && !waitlistToken)}
|
||||
className="bg-brand-600 text-white px-4 py-2 rounded hover:bg-brand-700 disabled:opacity-60"
|
||||
>
|
||||
{creatingRegistration ? "Registering..." : "Register"}
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -39,6 +40,9 @@ type KioskEvent = {
|
||||
picture?: string;
|
||||
eventOptions?: EventOption[];
|
||||
form?: EventForm | null;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
collectGender?: boolean;
|
||||
};
|
||||
|
||||
type Screen = "setup" | "form" | "eventform" | "confirmation";
|
||||
@@ -136,6 +140,9 @@ export default function SelfServicePage() {
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
|
||||
const [kioskCoupon, setKioskCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [namingOthers, setNamingOthers] = useState(false);
|
||||
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
|
||||
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
|
||||
// Set while a supervisor with two-factor on still has to enter their code.
|
||||
const [setupChallenge, setSetupChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||
const [setupCode, setSetupCode] = useState("");
|
||||
@@ -383,6 +390,10 @@ export default function SelfServicePage() {
|
||||
setFormError("The email and phone number entered belong to two different existing accounts. Please check and correct one of them before continuing.");
|
||||
return;
|
||||
}
|
||||
if (selectedEvent?.requiresParentalConsent) {
|
||||
const missing = consentDetailsMissing(selectedEvent.eventOptions || [], (o, v) => quantities[v ? `${o}::${v}` : o] || 0, attendeeEntries, selectedEvent.startDate);
|
||||
if (missing) { setFormError(missing); return; }
|
||||
}
|
||||
if (pendingChanges.length > 0) {
|
||||
setShowUpdateConfirm(true);
|
||||
return;
|
||||
@@ -412,6 +423,13 @@ export default function SelfServicePage() {
|
||||
notificationPreference: notificationPref,
|
||||
...(kioskCoupon ? { couponCode: kioskCoupon.code } : {}),
|
||||
};
|
||||
const kioskNames = (selectedEvent?.allowNamedAttendees && namingOthers) || selectedEvent?.requiresParentalConsent
|
||||
? attendeesPayload(selectedEvent.eventOptions || [], (o, v) => quantities[v ? `${o}::${v}` : o] || 0, attendeeEntries)
|
||||
: [];
|
||||
if (kioskNames.length > 0) {
|
||||
payload.attendees = kioskNames;
|
||||
payload.ticketDelivery = delivery;
|
||||
}
|
||||
if (createAccount && visitorPassword) {
|
||||
payload.user.password = visitorPassword;
|
||||
}
|
||||
@@ -526,6 +544,9 @@ export default function SelfServicePage() {
|
||||
|
||||
function resetForm() {
|
||||
setKioskCoupon(null);
|
||||
setNamingOthers(false);
|
||||
setAttendeeEntries({});
|
||||
setDelivery("payer");
|
||||
setVisitorName("");
|
||||
setVisitorEmail("");
|
||||
setVisitorPhone("");
|
||||
@@ -993,6 +1014,22 @@ export default function SelfServicePage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(selectedEvent?.allowNamedAttendees || selectedEvent?.requiresParentalConsent) && (
|
||||
<div className="mt-4">
|
||||
<AttendeeNamesEditor
|
||||
options={selectedEvent.eventOptions || []}
|
||||
qtyFor={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
|
||||
enabled={namingOthers}
|
||||
onEnabledChange={setNamingOthers}
|
||||
entries={attendeeEntries}
|
||||
onEntriesChange={setAttendeeEntries}
|
||||
delivery={delivery}
|
||||
onDeliveryChange={setDelivery}
|
||||
consentEventStart={selectedEvent.requiresParentalConsent ? selectedEvent.startDate : undefined}
|
||||
collectGender={!!selectedEvent.collectGender}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{total > 0 && selectedEventId && (
|
||||
<div className="mt-4">
|
||||
<CouponCodeField
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { ListChecks } from "lucide-react";
|
||||
|
||||
type Claim = {
|
||||
status: "offered" | "expired";
|
||||
eventId: string;
|
||||
eventTitle: string;
|
||||
eventStart: string;
|
||||
optionName: string;
|
||||
variantName: string | null;
|
||||
quantity: number;
|
||||
requested: number;
|
||||
expiresAt: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/** Landing page for a waitlist offer link — shows what's held, then goes to registration. */
|
||||
export default function WaitlistClaimPage() {
|
||||
const params = useParams<{ token: string }>();
|
||||
const token = params?.token;
|
||||
const [claim, setClaim] = useState<Claim | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<Claim>(`/api/waitlist/claim/${encodeURIComponent(token)}`)
|
||||
.then(setClaim)
|
||||
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this offer."));
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 px-4 py-10 max-w-xl mx-auto w-full">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<ListChecks className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Your waitlist place</h1>
|
||||
</div>
|
||||
{error && <div className="border rounded-xl p-4 bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{!claim && !error && <div className="text-sm text-gray-600">Loading…</div>}
|
||||
{claim && (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
|
||||
<div className="font-semibold text-gray-900">{claim.eventTitle}</div>
|
||||
<div className="text-sm text-gray-600">{formatDate(new Date(claim.eventStart))}</div>
|
||||
{claim.status === "offered" ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-700">
|
||||
{claim.name ? `Hi ${claim.name}, ` : ""}{claim.quantity} × {claim.optionName}{claim.variantName ? ` (${claim.variantName})` : ""} {claim.quantity === 1 ? "is" : "are"} being held for you until <strong>{new Date(claim.expiresAt).toLocaleString()}</strong>.
|
||||
{claim.quantity < claim.requested && ` You asked for ${claim.requested}; this is what came free.`}
|
||||
</p>
|
||||
<a href={`/register/${claim.eventId}?waitlist=${encodeURIComponent(token)}`} className="inline-block px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
|
||||
Claim and register
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-amber-900 bg-amber-50 border border-amber-200 rounded-lg p-3">This offer has expired, and the place has been offered to the next person on the waitlist.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ContactButton } from "@/components/events/ContactButton";
|
||||
price: number;
|
||||
picture?: string;
|
||||
isSoldOut?: boolean;
|
||||
waitlistEnabled?: boolean;
|
||||
requiresRegistration?: boolean;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
@@ -88,6 +89,14 @@ export const EventCard = ({ event }: { event: Event }) => {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (event.isSoldOut && event.waitlistEnabled) {
|
||||
// Sold out with a waitlist: send them to the event page to join it.
|
||||
return (
|
||||
<a href={`/events/${event.id}`} className="flex-1 text-center text-sm text-brand-700 bg-white border border-brand-300 rounded-lg py-2 hover:bg-brand-50">
|
||||
Sold out — join waitlist
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (event.isSoldOut) {
|
||||
return (
|
||||
<button disabled className="flex-1 text-center text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg py-2 cursor-not-allowed">
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ListPlus, X } from "lucide-react";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
/**
|
||||
* "Join waitlist" for sold-out options. Opens a small form: which option, how many,
|
||||
* and (for guests on events that allow it) name and contact details.
|
||||
*/
|
||||
export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine, compact = false }: {
|
||||
eventId: string;
|
||||
lines: WaitlistLine[];
|
||||
guestsAllowed: boolean;
|
||||
initialLine?: WaitlistLine;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { token, user } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [lineKey, setLineKey] = useState(() => keyOf(initialLine || lines[0]));
|
||||
const [qty, setQty] = useState(1);
|
||||
const [guest, setGuest] = useState({ name: "", email: "", phone: "" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
const loggedIn = !!(token && user);
|
||||
const line = lines.find(l => keyOf(l) === lineKey) || lines[0];
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>("/api/waitlist", {
|
||||
method: "POST",
|
||||
authToken: token || undefined,
|
||||
body: {
|
||||
eventId,
|
||||
eventOptionId: line.eventOptionId,
|
||||
...(line.variantId ? { variantId: line.variantId } : {}),
|
||||
quantity: qty,
|
||||
...(!loggedIn ? { guestName: guest.name.trim(), guestEmail: guest.email.trim(), guestPhone: guest.phone.trim() || undefined } : {}),
|
||||
},
|
||||
});
|
||||
setDone(res.message);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Couldn't join the waitlist. Please try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setOpen(true); setDone(null); setError(null); }}
|
||||
className={compact
|
||||
? "inline-flex items-center gap-1 text-xs font-medium text-brand-700 border border-brand-200 bg-brand-50 rounded px-2 py-0.5 hover:bg-brand-100"
|
||||
: "w-full inline-flex items-center justify-center gap-2 bg-white text-brand-700 border border-brand-300 px-4 py-2.5 rounded-lg hover:bg-brand-50 font-medium"}
|
||||
>
|
||||
<ListPlus className={compact ? "w-3.5 h-3.5" : "w-4 h-4"} /> Join waitlist
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setOpen(false)}>
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-xl w-full max-w-md p-5 space-y-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">Join the waitlist</h2>
|
||||
<button type="button" onClick={() => setOpen(false)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
</div>
|
||||
|
||||
{done ? (
|
||||
<>
|
||||
<p className="text-sm text-green-800 bg-green-50 border border-green-200 rounded-lg p-3">{done}</p>
|
||||
<button type="button" onClick={() => setOpen(false)} className="w-full px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">Done</button>
|
||||
</>
|
||||
) : !loggedIn && !guestsAllowed ? (
|
||||
<p className="text-sm text-gray-700">
|
||||
<a href={`/login?redirect=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "/events")}`} className="text-brand-600 font-medium underline">Log in</a> to join the waitlist for this event.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-600">If a place opens up, we'll email or WhatsApp you a link to claim it. We hold it for you for a limited time.</p>
|
||||
{lines.length > 1 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ticket</label>
|
||||
<select className={inputCls} value={lineKey} onChange={e => setLineKey(e.target.value)}>
|
||||
{lines.map(l => <option key={keyOf(l)} value={keyOf(l)}>{l.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{lines.length === 1 && <p className="text-sm"><span className="text-gray-500">Ticket:</span> {line.label}</p>}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">How many?</label>
|
||||
<input type="number" min={1} max={20} className={`${inputCls} w-24`} value={qty} onChange={e => setQty(Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 1)))} />
|
||||
</div>
|
||||
{!loggedIn && (
|
||||
<div className="space-y-2">
|
||||
<input className={inputCls} placeholder="Full name *" value={guest.name} onChange={e => setGuest({ ...guest, name: e.target.value })} required aria-label="Full name" />
|
||||
<input type="email" className={inputCls} placeholder="Email *" value={guest.email} onChange={e => setGuest({ ...guest, email: e.target.value })} required aria-label="Email" />
|
||||
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={guest.phone} onChange={e => setGuest({ ...guest, phone: e.target.value })} aria-label="Phone" />
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button type="submit" disabled={busy} className="w-full px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{busy ? "Joining…" : "Join waitlist"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function keyOf(l?: WaitlistLine) {
|
||||
return l ? `${l.eventOptionId}::${l.variantId || ""}` : "";
|
||||
}
|
||||
|
||||
/** The sold-out lines of an event, for the waitlist button. */
|
||||
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {
|
||||
const out: WaitlistLine[] = [];
|
||||
for (const o of options || []) {
|
||||
const optOut = (o.stockLimit ?? 0) > 0 && o.availableCount !== undefined && o.availableCount !== null && o.availableCount <= 0;
|
||||
const variants = o.variants || [];
|
||||
if (variants.length > 0) {
|
||||
for (const v of variants) {
|
||||
const vOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount !== null && v.availableCount <= 0;
|
||||
if (optOut || vOut) out.push({ eventOptionId: o.id, variantId: v.id, label: `${o.name} (${v.name})` });
|
||||
}
|
||||
} else if (optOut) {
|
||||
out.push({ eventOptionId: o.id, label: o.name });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
BarChart3,
|
||||
ClipboardList,
|
||||
TicketPercent,
|
||||
ShieldCheck,
|
||||
HeartPulse,
|
||||
} from "lucide-react";
|
||||
|
||||
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
|
||||
@@ -34,6 +36,8 @@ export const REPORTS = [
|
||||
{ key: "financeReport", label: "Finance report (revenue & costs)", description: "Revenue & costs overview", category: "Financial", icon: FileBarChart, fields: 6 },
|
||||
{ key: "profitReport", label: "Profit report", description: "View profit report", category: "Financial", icon: BarChart3, fields: 5 },
|
||||
{ key: "cashupAudit", label: "Cashup audit trail", description: "Audit trail of cashup actions", category: "Cashup", icon: ClipboardList, fields: 6 },
|
||||
{ key: "consentStatus", label: "Parental consent status", description: "Youth events: consent per attendee", category: "Registration", icon: ShieldCheck, fields: 5 },
|
||||
{ key: "medicalList", label: "Medical & emergency list", description: "Youth events: confidential list for leaders (audited)", category: "Registration", icon: HeartPulse, fields: 5 },
|
||||
{ key: "discounts", label: "Discounts (coupon codes)", description: "Uses and value given away per code and event", category: "Financial", icon: TicketPercent, fields: 4 },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
|
||||
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent } from "lucide-react";
|
||||
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck } from "lucide-react";
|
||||
import { REPORTS, type ReportKey } from "./ReportCatalog";
|
||||
import ReportsShell from "./ReportsShell";
|
||||
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
|
||||
@@ -174,6 +174,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
const [financialsByEvent, setFinancialsByEvent] = useState<Record<string, any>>({});
|
||||
const [auditRows, setAuditRows] = useState<any[]>([]);
|
||||
const [discountRows, setDiscountRows] = useState<any[]>([]);
|
||||
// Youth events (one row per attendee), each tagged with its event title.
|
||||
const [consentRows, setConsentRows] = useState<any[]>([]);
|
||||
const [medicalRows, setMedicalRows] = useState<any[]>([]);
|
||||
|
||||
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
|
||||
// straight into a specific report, pre-filtered to every currently-visible event and the
|
||||
@@ -296,6 +299,20 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const loadConsent = async (eventIds: string[], kind: "status" | "medical") => {
|
||||
if (!token) return [] as any[];
|
||||
const out: any[] = [];
|
||||
for (const id of eventIds) {
|
||||
const ev = filteredEvents.find((e: any) => e.id === id);
|
||||
if (!ev?.requiresParentalConsent) continue;
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/consent/events/${encodeURIComponent(id)}${kind === "medical" ? "/medical" : ""}`, { authToken: token });
|
||||
(res?.rows || []).forEach((r: any) => out.push({ ...r, eventTitle: ev.title }));
|
||||
} catch { /* not allowed or not found — skip */ }
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const loadCashupAudit = async (eventIds: string[]) => {
|
||||
if (!token) return [] as any[];
|
||||
const query = new URLSearchParams();
|
||||
@@ -390,6 +407,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
setAuditRows(rows);
|
||||
} else if (report === "discounts") {
|
||||
setDiscountRows(await loadDiscounts(selectedEventIds));
|
||||
} else if (report === "consentStatus") {
|
||||
setConsentRows(await loadConsent(selectedEventIds, "status"));
|
||||
} else if (report === "medicalList") {
|
||||
setMedicalRows(await loadConsent(selectedEventIds, "medical"));
|
||||
}
|
||||
setReady(true);
|
||||
} finally {
|
||||
@@ -949,7 +970,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 {
|
||||
@@ -960,7 +984,35 @@ 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") {
|
||||
const minors = consentRows.filter(r => r.minor);
|
||||
const count = (s: string) => minors.filter(r => r.status === s).length;
|
||||
return {
|
||||
title: 'Parental consent status', subtitle, kind: 'table', orientation: 'landscape',
|
||||
stats: [
|
||||
{ label: 'Given', value: String(count('given')), tone: 'green' },
|
||||
{ label: 'Pending', value: String(count('pending')), tone: 'amber' },
|
||||
{ label: 'Declined / missing', value: String(count('declined') + count('missing')), tone: 'rose' },
|
||||
],
|
||||
note: 'Under-18 attendees on youth events. Pending, declined or missing consent blocks check-in.',
|
||||
table: {
|
||||
columns: ["Event", "Attendee", "Date of birth", "Parent / guardian", "Consent", "Answered"],
|
||||
rows: minors.map(r => [r.eventTitle, r.name, r.dateOfBirth ? new Date(r.dateOfBirth).toLocaleDateString() : '', [r.parentName, r.parentPhone].filter(Boolean).join(' · '), r.status + (r.request?.outdated ? ' (older text)' : ''), r.request?.respondedAt ? `${r.request.signedName || ''} ${new Date(r.request.respondedAt).toLocaleDateString()}`.trim() : '']),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (report === "medicalList") {
|
||||
const fmt = (m: any) => m ? [m.allergies && `Allergies: ${m.allergies}`, m.medication && `Medication: ${m.medication}`, m.conditions && `Conditions: ${m.conditions}`, m.medicalAid && `Medical aid: ${m.medicalAid}`].filter(Boolean).join('; ') : '';
|
||||
return {
|
||||
title: 'Medical & emergency list', subtitle, kind: 'table', orientation: 'landscape',
|
||||
note: 'CONFIDENTIAL — for event leaders only. Viewing this list is recorded in the audit log.',
|
||||
table: {
|
||||
columns: ["Event", "Attendee", "Medical", "Emergency contact", "Parent / guardian", "Photos"],
|
||||
rows: medicalRows.map(r => [r.eventTitle, r.name, r.purged ? 'Deleted after the event' : fmt(r.medical), r.emergency ? `${r.emergency.name} ${r.emergency.phone}${r.emergency.relationship ? ` (${r.emergency.relationship})` : ''}` : '', [r.parentName, r.parentPhone].filter(Boolean).join(' · '), r.photoConsent === null || r.photoConsent === undefined ? '' : r.photoConsent ? 'Yes' : 'No']),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (report === "discounts") {
|
||||
@@ -1168,6 +1220,8 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
profitReport: "Profit Report",
|
||||
cashupAudit: "Cashup Audit Trail",
|
||||
discounts: "Discounts Report",
|
||||
consentStatus: "Parental Consent Status",
|
||||
medicalList: "Medical & Emergency List",
|
||||
};
|
||||
|
||||
const doPrintPdf = async () => {
|
||||
@@ -1618,6 +1672,41 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(report === "consentStatus" || report === "medicalList") && (() => {
|
||||
const payload: any = buildReportPayload();
|
||||
const rows: any[] = payload?.table?.rows || [];
|
||||
const cols: string[] = payload?.table?.columns || [];
|
||||
return (
|
||||
<div>
|
||||
{report === "medicalList" && <div className="mb-3 text-sm text-red-700 font-medium">Confidential — for event leaders only. Viewing this list is recorded in the audit log.</div>}
|
||||
{payload?.stats && (
|
||||
<StatTileRow>
|
||||
{payload.stats.map((s: any) => <StatTile key={s.label} icon={ShieldCheck} label={s.label} value={s.value} tone={s.tone} />)}
|
||||
</StatTileRow>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 mt-3">Nothing to show — pick youth events (ones that need parental consent) in the filters.</div>
|
||||
) : (
|
||||
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
|
||||
<table className="min-w-[760px] w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
|
||||
{cols.map(c => <th key={c} className="py-2 px-3">{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="border-t border-gray-100 align-top">
|
||||
{r.map((cell: any, j: number) => <td key={j} className="py-2 px-3">{String(cell ?? '')}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{report === "discounts" && (
|
||||
<div>
|
||||
{discountSummary.rows.length === 0 ? (
|
||||
@@ -1689,7 +1778,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 (
|
||||
@@ -1713,6 +1805,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>
|
||||
@@ -1724,6 +1819,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">
|
||||
@@ -1733,6 +1831,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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
export type AttendeeOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
isMainTicket?: boolean;
|
||||
variants?: { id: string; name: string }[] | null;
|
||||
};
|
||||
|
||||
export type AttendeeEntry = {
|
||||
name: string; email: string; phone: string; 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; gender?: string;
|
||||
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
|
||||
};
|
||||
|
||||
/** Under 18 on the event's start date (mirrors backend utils/consent.js isMinor). */
|
||||
export function isMinorOn(dateOfBirth: string | undefined, eventStart: string | undefined): boolean {
|
||||
if (!dateOfBirth || !eventStart) return false;
|
||||
const dob = new Date(dateOfBirth);
|
||||
const on = new Date(eventStart);
|
||||
if (isNaN(dob.getTime()) || isNaN(on.getTime())) return false;
|
||||
let age = on.getUTCFullYear() - dob.getUTCFullYear();
|
||||
if (on.getUTCMonth() < dob.getUTCMonth() || (on.getUTCMonth() === dob.getUTCMonth() && on.getUTCDate() < dob.getUTCDate())) age -= 1;
|
||||
return age < 18;
|
||||
}
|
||||
|
||||
type Slot = { key: string; eventOptionId: string; variantId: string | null; label: string };
|
||||
|
||||
/**
|
||||
* One slot per main-ticket place in the basket. `qtyFor(optionId, variantId)` reads the
|
||||
* caller's quantities (each page keys its quantity map differently).
|
||||
*/
|
||||
function slotsFor(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number): Slot[] {
|
||||
const slots: Slot[] = [];
|
||||
for (const o of options) {
|
||||
if (!o.isMainTicket) continue;
|
||||
const variants = o.variants || [];
|
||||
const lines = variants.length > 0 ? variants.map(v => ({ variantId: v.id, label: `${o.name} (${v.name})` })) : [{ variantId: null, label: o.name }];
|
||||
for (const line of lines) {
|
||||
const qty = qtyFor(o.id, line.variantId);
|
||||
for (let i = 0; i < qty; i++) {
|
||||
slots.push({ key: `${o.id}::${line.variantId || ""}#${i}`, eventOptionId: o.id, variantId: line.variantId, label: `${line.label} ${i + 1}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
/** The named places as the API expects them (unnamed places are left out). */
|
||||
export function attendeesPayload(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number, entries: AttendeeEntries): AttendeePayload[] {
|
||||
return slotsFor(options, qtyFor)
|
||||
.map(s => ({ s, e: entries[s.key] }))
|
||||
.filter(({ e }) => e && e.name.trim())
|
||||
.map(({ s, e }) => ({
|
||||
eventOptionId: s.eventOptionId,
|
||||
...(s.variantId ? { variantId: s.variantId } : {}),
|
||||
name: e.name.trim(),
|
||||
...(e.email.trim() ? { email: e.email.trim() } : {}),
|
||||
...(e.phone.trim() ? { phone: e.phone.trim() } : {}),
|
||||
...(e.dateOfBirth ? { dateOfBirth: e.dateOfBirth } : {}),
|
||||
...(e.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() } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Youth events: what's still missing before registering (every place named with a date of
|
||||
* birth; parent details for minors). Empty when everything's in.
|
||||
*/
|
||||
export function consentDetailsMissing(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number, entries: AttendeeEntries, eventStart: string): string | null {
|
||||
for (const s of slotsFor(options, qtyFor)) {
|
||||
const e = entries[s.key];
|
||||
if (!e?.name.trim()) return `Add a name for ${s.label}.`;
|
||||
if (!e.dateOfBirth) return `Add a date of birth for ${e.name.trim()}.`;
|
||||
if (isMinorOn(e.dateOfBirth, eventStart) && (!e.parentName?.trim() || !e.parentEmail?.trim() || !e.parentPhone?.trim())) {
|
||||
return `${e.name.trim()} is under 18 — add a parent or guardian's name, email and phone.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Registering for other people?" — a name (and optional email/phone) for each main
|
||||
* ticket place, plus who gets the tickets. Named places get their own ticket; any left
|
||||
* blank share one ticket as usual.
|
||||
*/
|
||||
export function AttendeeNamesEditor({
|
||||
options,
|
||||
qtyFor,
|
||||
enabled,
|
||||
onEnabledChange,
|
||||
entries,
|
||||
onEntriesChange,
|
||||
delivery,
|
||||
onDeliveryChange,
|
||||
payerLabel = "me",
|
||||
consentEventStart,
|
||||
collectGender = false,
|
||||
}: {
|
||||
options: AttendeeOption[];
|
||||
qtyFor: (optionId: string, variantId: string | null) => number;
|
||||
enabled: boolean;
|
||||
onEnabledChange: (v: boolean) => void;
|
||||
entries: AttendeeEntries;
|
||||
onEntriesChange: (v: AttendeeEntries) => void;
|
||||
delivery: TicketDelivery;
|
||||
onDeliveryChange: (v: TicketDelivery) => void;
|
||||
payerLabel?: string;
|
||||
/** Set for youth events: names are required, with a date of birth, and parent details for minors. */
|
||||
consentEventStart?: string;
|
||||
/** Ask each person's gender (for gender-split rooms). */
|
||||
collectGender?: boolean;
|
||||
}) {
|
||||
const consent = !!consentEventStart;
|
||||
const slots = slotsFor(options, qtyFor);
|
||||
const set = (key: string, field: keyof AttendeeEntry, value: string) => {
|
||||
const prev: AttendeeEntry = entries[key] || { name: "", email: "", phone: "" };
|
||||
onEntriesChange({ ...entries, [key]: { ...prev, [field]: value } });
|
||||
};
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
{consent ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-800">
|
||||
<Users className="w-4 h-4 text-brand-600" /> Who's coming?
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">This is a youth event: add each person's name and date of birth. For anyone under 18, a parent or guardian will be asked to give consent by email and WhatsApp before they can be checked in.</p>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-800 cursor-pointer">
|
||||
<input type="checkbox" checked={enabled} onChange={e => onEnabledChange(e.target.checked)} />
|
||||
<Users className="w-4 h-4 text-brand-600" />
|
||||
Registering for other people?
|
||||
</label>
|
||||
)}
|
||||
{(enabled || consent) && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{slots.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Choose how many tickets first, then add a name for each person.</p>
|
||||
) : (
|
||||
<>
|
||||
{!consent && <p className="text-xs text-gray-500">Each named person gets their own ticket and their own attendee form. Leave a name blank to keep that ticket with the others.</p>}
|
||||
{slots.map(s => {
|
||||
const e = entries[s.key] || { name: "", email: "", phone: "" };
|
||||
return (
|
||||
<div key={s.key} className="border rounded-lg p-3">
|
||||
<div className="text-xs font-semibold text-gray-600 mb-2">{s.label}</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input className={inputCls} placeholder="Full name" value={e.name} onChange={ev => set(s.key, "name", ev.target.value)} aria-label={`${s.label} name`} />
|
||||
<input type="email" className={inputCls} placeholder="Email (optional)" value={e.email} onChange={ev => set(s.key, "email", ev.target.value)} aria-label={`${s.label} email`} />
|
||||
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={e.phone} onChange={ev => set(s.key, "phone", ev.target.value)} aria-label={`${s.label} phone`} />
|
||||
</div>
|
||||
{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>
|
||||
<input type="date" className={`${inputCls} sm:w-48`} value={e.dateOfBirth || ""} max={new Date().toISOString().slice(0, 10)} onChange={ev => set(s.key, "dateOfBirth", ev.target.value)} aria-label={`${s.label} date of birth`} />
|
||||
{isMinorOn(e.dateOfBirth, consentEventStart) && (
|
||||
<div className="mt-2 rounded-lg bg-amber-50 border border-amber-200 p-2">
|
||||
<div className="text-xs font-medium text-amber-900 mb-1">Parent or guardian (they'll be asked for consent)</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input className={inputCls} placeholder="Parent/guardian name" value={e.parentName || ""} onChange={ev => set(s.key, "parentName", ev.target.value)} aria-label={`${s.label} parent name`} />
|
||||
<input type="email" className={inputCls} placeholder="Parent email" value={e.parentEmail || ""} onChange={ev => set(s.key, "parentEmail", ev.target.value)} aria-label={`${s.label} parent email`} />
|
||||
<input type="tel" className={inputCls} placeholder="Parent phone" value={e.parentPhone || ""} onChange={ev => set(s.key, "parentPhone", ev.target.value)} aria-label={`${s.label} parent phone`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<fieldset className="pt-1">
|
||||
<legend className="text-sm font-medium text-gray-700 mb-1">Send the tickets</legend>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="radio" name="ticket-delivery" checked={delivery === "payer"} onChange={() => onDeliveryChange("payer")} /> All to {payerLabel}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="radio" name="ticket-delivery" checked={delivery === "each"} onChange={() => onDeliveryChange("each")} /> Each person their own (by their email or WhatsApp; anyone without contact details goes to {payerLabel})
|
||||
</label>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Users, Send } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||
|
||||
const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
pending: { text: "consent pending", cls: "text-amber-700" },
|
||||
given: { text: "consent given", cls: "text-green-700" },
|
||||
declined: { text: "consent declined", cls: "text-red-700" },
|
||||
};
|
||||
|
||||
type Attendee = {
|
||||
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
|
||||
dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||
ticket?: { id: string; isUsed: boolean } | null;
|
||||
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
|
||||
};
|
||||
type Row = {
|
||||
id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean;
|
||||
dateOfBirth: string; gender: string; parentName: string; parentEmail: string; parentPhone: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Named attendees on a registration (group tickets): shows who each main-ticket place
|
||||
* is for, and lets the payer (or staff) name places, change details, choose where the
|
||||
* tickets go, and send an attendee the link to their own form.
|
||||
*/
|
||||
export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, onMessage }: {
|
||||
registration: any;
|
||||
token: string;
|
||||
canEdit: boolean;
|
||||
hasForm: boolean;
|
||||
onSaved: () => void;
|
||||
onMessage: (msg: string) => void;
|
||||
}) {
|
||||
const attendees: Attendee[] = registration.attendees || [];
|
||||
const youth = !!registration.event?.requiresParentalConsent;
|
||||
const collectGender = !!registration.event?.collectGender;
|
||||
const eventStart: string | undefined = registration.event?.startDate;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [delivery, setDelivery] = useState<"payer" | "each">(registration.ticketDelivery || "payer");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// One row per main-ticket place: named ones first (in order), then blanks.
|
||||
const buildRows = useMemo(() => () => {
|
||||
const out: Row[] = [];
|
||||
for (const ro of registration.registrationOptions || []) {
|
||||
if (!ro.eventOption?.isMainTicket) continue;
|
||||
const label = `${ro.eventOption?.name || "Ticket"}${ro.variant?.name ? ` (${ro.variant.name})` : ""}`;
|
||||
const named = attendees.filter(a => a.eventOptionId === ro.eventOptionId && (a.variantId || null) === (ro.variantId || null));
|
||||
for (let i = 0; i < (ro.quantity || 0); i++) {
|
||||
const a = named[i];
|
||||
out.push({
|
||||
id: a?.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId || null, label: `${label} ${i + 1}`,
|
||||
name: a?.name || "", email: a?.email || "", phone: a?.phone || "",
|
||||
dateOfBirth: a?.dateOfBirth ? String(a.dateOfBirth).slice(0, 10) : "",
|
||||
gender: a?.gender || "",
|
||||
parentName: a?.parentName || "", parentEmail: a?.parentEmail || "", parentPhone: a?.parentPhone || "",
|
||||
locked: !!a?.ticket?.isUsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [registration, attendees]);
|
||||
|
||||
if (!registration.event?.allowNamedAttendees && !youth && attendees.length === 0) return null;
|
||||
|
||||
const startEdit = () => { setRows(buildRows()); setDelivery(registration.ticketDelivery || "payer"); setError(null); setEditing(true); };
|
||||
const update = (i: number, field: "name" | "email" | "phone" | "dateOfBirth" | "gender" | "parentName" | "parentEmail" | "parentPhone", value: string) =>
|
||||
setRows(prev => prev.map((r, j) => (j === i ? { ...r, [field]: value } : r)));
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/attendees`, {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: {
|
||||
ticketDelivery: delivery,
|
||||
attendees: rows.filter(r => r.name.trim()).map(r => ({
|
||||
...(r.id ? { id: r.id } : {}),
|
||||
eventOptionId: r.eventOptionId,
|
||||
...(r.variantId ? { variantId: r.variantId } : {}),
|
||||
name: r.name.trim(),
|
||||
email: r.email.trim() || undefined,
|
||||
phone: r.phone.trim() || undefined,
|
||||
...(r.dateOfBirth ? { dateOfBirth: r.dateOfBirth } : {}),
|
||||
...(r.gender ? { gender: r.gender } : {}),
|
||||
...(r.parentName.trim() ? { parentName: r.parentName.trim() } : {}),
|
||||
...(r.parentEmail.trim() ? { parentEmail: r.parentEmail.trim() } : {}),
|
||||
...(r.parentPhone.trim() ? { parentPhone: r.parentPhone.trim() } : {}),
|
||||
})),
|
||||
},
|
||||
});
|
||||
setEditing(false);
|
||||
onMessage("Attendees saved.");
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't save attendees");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendLink = async (a: Attendee) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/registrations/${encodeURIComponent(registration.id)}/attendees/${encodeURIComponent(a.id)}/send-form-link`, { method: "POST", authToken: token });
|
||||
onMessage(res.message);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't send the form link");
|
||||
}
|
||||
};
|
||||
|
||||
const answered = new Set((registration.formResponses || []).map((r: any) => r.attendeeId).filter(Boolean));
|
||||
|
||||
const resendConsent = async (requestId: string) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/consent/requests/${encodeURIComponent(requestId)}/resend`, { method: "POST", authToken: token });
|
||||
onMessage(res.message);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't re-send the consent request");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium mb-1 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5"><Users className="w-4 h-4 text-brand-600" /> People</span>
|
||||
{canEdit && !editing && (
|
||||
<button onClick={startEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700">{attendees.length ? "Edit names" : "Add names"}</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!editing ? (
|
||||
attendees.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No names yet — add a name for each person to give them their own ticket.</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="text-sm space-y-1">
|
||||
{attendees.map(a => (
|
||||
<li key={a.id} className="flex items-center justify-between gap-2">
|
||||
<span>
|
||||
{a.name}
|
||||
{(a.email || a.phone) && <span className="text-xs text-gray-500"> · {[a.email, a.phone].filter(Boolean).join(" · ")}</span>}
|
||||
{hasForm && <span className={`ml-2 text-xs ${answered.has(a.id) ? "text-green-700" : "text-amber-700"}`}>{answered.has(a.id) ? "form done" : "form to do"}</span>}
|
||||
{youth && a.consentRequests?.[0] && (
|
||||
<span className={`ml-2 text-xs ${CONSENT_LABEL[a.consentRequests[0].status]?.cls}`}>{CONSENT_LABEL[a.consentRequests[0].status]?.text}</span>
|
||||
)}
|
||||
</span>
|
||||
{canEdit && youth && a.consentRequests?.[0]?.status === "pending" && (
|
||||
<button onClick={() => resendConsent(a.consentRequests![0].id)} className="text-xs text-brand-700 hover:underline shrink-0 inline-flex items-center gap-1"><Send className="w-3 h-3" /> Resend consent</button>
|
||||
)}
|
||||
{canEdit && hasForm && !answered.has(a.id) && (a.email || a.phone) && (
|
||||
<button onClick={() => sendLink(a)} className="text-xs text-brand-700 hover:underline shrink-0 inline-flex items-center gap-1"><Send className="w-3 h-3" /> Send form link</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-gray-500 mt-1">Tickets go {registration.ticketDelivery === "each" ? "to each person (or you, if they have no contact details)" : "to you"}.</p>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="border rounded p-3 space-y-2 bg-gray-50">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i}>
|
||||
<div className="text-xs font-semibold text-gray-600 mb-1">{r.label}{r.locked && <span className="font-normal text-gray-500"> — checked in, can't be changed</span>}</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Full name" value={r.name} disabled={r.locked} onChange={e => update(i, "name", e.target.value)} aria-label={`${r.label} name`} />
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Email (optional)" value={r.email} disabled={r.locked} onChange={e => update(i, "email", e.target.value)} aria-label={`${r.label} email`} />
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Phone (optional)" value={r.phone} disabled={r.locked} onChange={e => update(i, "phone", e.target.value)} aria-label={`${r.label} phone`} />
|
||||
</div>
|
||||
{collectGender && (
|
||||
<select className="mt-1 border rounded px-2 py-1 text-sm" value={r.gender} disabled={r.locked} onChange={e => update(i, "gender", e.target.value)} aria-label={`${r.label} gender`}>
|
||||
<option value="">Gender…</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
)}
|
||||
{youth && (
|
||||
<div className="mt-1 space-y-1">
|
||||
<input type="date" className="border rounded px-2 py-1 text-sm" value={r.dateOfBirth} disabled={r.locked} onChange={e => update(i, "dateOfBirth", e.target.value)} aria-label={`${r.label} date of birth`} />
|
||||
{isMinorOn(r.dateOfBirth, eventStart) && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent/guardian name" value={r.parentName} disabled={r.locked} onChange={e => update(i, "parentName", e.target.value)} aria-label={`${r.label} parent name`} />
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent email" value={r.parentEmail} disabled={r.locked} onChange={e => update(i, "parentEmail", e.target.value)} aria-label={`${r.label} parent email`} />
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent phone" value={r.parentPhone} disabled={r.locked} onChange={e => update(i, "parentPhone", e.target.value)} aria-label={`${r.label} parent phone`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-sm pt-1">
|
||||
<label className="flex items-center gap-2"><input type="radio" checked={delivery === "payer"} onChange={() => setDelivery("payer")} /> Send all tickets to me</label>
|
||||
<label className="flex items-center gap-2"><input type="radio" checked={delivery === "each"} onChange={() => setDelivery("each")} /> Send each person their own</label>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setEditing(false)} className="px-3 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" disabled={busy}>Cancel</button>
|
||||
<button onClick={save} disabled={busy} className="px-3 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">{busy ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!editing && error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
status: "waiting" | "offered";
|
||||
quantity: number;
|
||||
offeredQuantity: number | null;
|
||||
offerExpiresAt: string | null;
|
||||
event: { id: string; title: string; startDate: string };
|
||||
eventOption: { name: string };
|
||||
variant: { name: string } | null;
|
||||
};
|
||||
|
||||
/** My Events: the waitlists I'm on, with Leave (and a note when a place is on offer). */
|
||||
export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m: string) => void }) {
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const load = useCallback(() => {
|
||||
apiFetch<Entry[]>("/api/waitlist/mine", { authToken: token }).then(setEntries).catch(() => setEntries([]));
|
||||
}, [token]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const leave = async (id: string) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/waitlist/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token });
|
||||
onMessage(res.message);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
onMessage(e?.message || "Couldn't leave the waitlist");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 font-semibold text-gray-900 mb-2">
|
||||
<ListChecks className="w-4 h-4 text-brand-600" /> Waitlists
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{entries.map(e => (
|
||||
<li key={e.id} className="py-2 flex items-center justify-between gap-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">{e.event.title}</div>
|
||||
<div className="text-xs text-gray-500">{e.quantity} × {e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</div>
|
||||
{e.status === "offered" ? (
|
||||
<div className="text-xs text-green-700 mt-0.5">
|
||||
{e.offeredQuantity} place(s) held for you until {e.offerExpiresAt ? new Date(e.offerExpiresAt).toLocaleString() : ""} — use the link we sent you to claim.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 mt-0.5">Waiting — we'll email or WhatsApp you if a place opens up.</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => leave(e.id)} className="px-2.5 py-1 text-xs rounded border hover:bg-gray-50 shrink-0">Leave</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ArrowRightLeft } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||
|
||||
/** Whether to offer "Transfer" on a ticket (the server checks the rules again). */
|
||||
export function canOfferTransfer(ticket: any, event: any, isSupervisor = false): boolean {
|
||||
if (!ticket || !event) return false;
|
||||
if ((ticket.usages || []).length > 0 || ticket.isUsed) return false;
|
||||
if (event.startDate && new Date(event.startDate) <= new Date()) return false;
|
||||
return isSupervisor || event.allowTicketTransfer !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a ticket to someone else: their name and email/phone (plus date of birth and a
|
||||
* parent's details on youth events). They get a new QR code; the old one stops working.
|
||||
*/
|
||||
export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
ticket: any;
|
||||
event: any;
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const youth = !!event?.requiresParentalConsent;
|
||||
const [f, setF] = useState({ name: "", email: "", phone: "", gender: "", dateOfBirth: "", parentName: "", parentEmail: "", parentPhone: "" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const minor = youth && isMinorOn(f.dateOfBirth, event?.startDate);
|
||||
const places = ticket.quantity || 1;
|
||||
const set = (k: keyof typeof f) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => setF(prev => ({ ...prev, [k]: e.target.value }));
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body: Record<string, string> = { name: f.name, email: f.email, phone: f.phone, ...(f.gender ? { gender: f.gender } : {}) };
|
||||
if (youth) Object.assign(body, { dateOfBirth: f.dateOfBirth, parentName: f.parentName, parentEmail: f.parentEmail, parentPhone: f.parentPhone });
|
||||
const res = await apiFetch<{ message: string }>(`/api/tickets/${ticket.id}/transfer`, { method: "POST", authToken: token, body });
|
||||
onDone(res.message);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't transfer the ticket");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const input = "w-full border rounded-lg px-3 py-2 text-sm";
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<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 gap-2">
|
||||
<ArrowRightLeft className="w-5 h-5 text-brand-600" />
|
||||
<h3 className="text-lg font-semibold">Transfer ticket</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{places > 1
|
||||
? `This ticket is for ${places} people. One place moves to the new holder on their own ticket; the other ${places - 1} stay on this one.`
|
||||
: "The new holder gets their own ticket with a new QR code. The current QR code (including any screenshots of it) will stop working."}
|
||||
{" "}The booking and payment stay with the person who booked.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">New holder's name</label>
|
||||
<input className={input} value={f.name} onChange={set("name")} required maxLength={100} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Email</label>
|
||||
<input type="email" className={input} value={f.email} onChange={set("email")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Phone (WhatsApp)</label>
|
||||
<input type="tel" className={input} value={f.phone} onChange={set("phone")} />
|
||||
</div>
|
||||
</div>
|
||||
{event?.collectGender && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Gender</label>
|
||||
<select className={input} value={f.gender} onChange={set("gender")} required>
|
||||
<option value="">Choose…</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">An email or phone number is needed to send them the ticket. If they don't have an account yet, one is created and they get a link to activate it.</p>
|
||||
{youth && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
|
||||
<input type="date" className={input} value={f.dateOfBirth} onChange={set("dateOfBirth")} required />
|
||||
</div>
|
||||
{minor && (
|
||||
<>
|
||||
<p className="text-xs text-amber-800 bg-amber-50 rounded p-2">They're under 18, so a parent or guardian will be asked for consent before they can check in.</p>
|
||||
<input className={input} placeholder="Parent or guardian's name" value={f.parentName} onChange={set("parentName")} required />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<input type="email" className={input} placeholder="Parent's email" value={f.parentEmail} onChange={set("parentEmail")} required />
|
||||
<input type="tel" className={input} placeholder="Parent's phone" value={f.parentPhone} onChange={set("parentPhone")} required />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
|
||||
<button type="submit" disabled={busy || !f.name.trim() || (!f.email.trim() && !f.phone.trim())} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{busy ? "Transferring…" : "Transfer"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText } from "lucide-react";
|
||||
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users, ArrowRightLeft } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -39,6 +39,9 @@ export const dashboardUserHelpContent: HelpContent = {
|
||||
<GuideItem icon={History} title="Show past events" tone="gray">
|
||||
Registrations page defaults to upcoming events only — tick "Show past events" at the top to bring back ones that have already happened.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="People on a group booking" tone="brand">
|
||||
On events that take names, the registration popup has a People section: add or change each person's name and contact details, choose whether tickets come to you or go to each person, and send someone the link to fill in their own attendee form. Tickets someone else booked for you show here too, marked "Booked by".
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -57,6 +60,9 @@ export const dashboardUserHelpContent: HelpContent = {
|
||||
<GuideItem icon={Printer} title="Print, email, or WhatsApp" tone="green">
|
||||
Get your selected tickets onto your phone or paper before the event — as a printed page, an email, or a WhatsApp message.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ArrowRightLeft} title="Transfer a ticket" tone="violet">
|
||||
Can't make it? Tap "Transfer" to give your ticket to someone else before the event. Enter their name and email or phone, and they get a new ticket with a new QR code. The old code (and any screenshots of it) stops working. For a ticket covering several people, one place moves to them. You stay responsible for the booking and payment. A ticket can't be transferred once it has been scanned.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Ticket, Tag, Share2, QrCode, Calendar } from "lucide-react";
|
||||
import { Ticket, Tag, Share2, QrCode, Calendar, Users, HeartHandshake } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -23,7 +23,13 @@ export const eventDetailHelpContent: HelpContent = {
|
||||
If a discounted price is shown with the original struck through, an early-bird window is active — the price rises automatically once it ends. Got a discount code? Enter it on the registration page under "Have a code?" (you need to be logged in).
|
||||
</GuideItem>
|
||||
<GuideItem icon={Ticket} title="Availability badges" tone="brand">
|
||||
"N remaining" appears once stock runs low, and "Sold out" once an option is fully booked.
|
||||
"N remaining" appears once stock runs low, and "Sold out" once an option is fully booked. Some events have a waitlist: tap "Join waitlist" and you'll be emailed or WhatsApped a link to claim a place if one opens up (it's held for you for a limited time).
|
||||
</GuideItem>
|
||||
<GuideItem icon={HeartHandshake} title="Sponsor a spot" tone="rose">
|
||||
Some events let you pay for a place for someone who can't afford it. Tap "Sponsor a spot", choose one or more spots (or any amount), and pay securely with Yoco. You can give anonymously or add a name and a message for the organisers. You'll get a receipt, and who is helped is never shared.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="Registering for other people?" tone="violet">
|
||||
Some events let you name each person you're booking for. Each named person gets their own ticket and their own attendee form, and you can choose to have every ticket sent to you or each person their own. On youth events, you'll give each person's date of birth; for anyone under 18, their parent or guardian is asked to give consent by email and WhatsApp before they can be checked in.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,10 @@ import { supervisorAtTheDoorHelpContent } from "./supervisor-at-the-door";
|
||||
import { supervisorPaymentsHelpContent } from "./supervisor-payments";
|
||||
import { supervisorFormsHelpContent } from "./supervisor-forms";
|
||||
import { supervisorCouponsHelpContent } from "./supervisor-coupons";
|
||||
import { supervisorConsentHelpContent } from "./supervisor-consent";
|
||||
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
|
||||
import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans";
|
||||
import { supervisorAllocationsHelpContent, staffMyGroupsHelpContent } from "./supervisor-allocations";
|
||||
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
|
||||
import { supervisorWhatsappAttendeesHelpContent } from "./supervisor-whatsapp-attendees";
|
||||
import { staffEventTicketsHelpContent } from "./staff-event-tickets";
|
||||
@@ -148,6 +152,31 @@ const REGISTRY: HelpRegistryEntry[] = [
|
||||
matches: pathname => pathname === "/dashboard/supervisor/payments",
|
||||
content: supervisorPaymentsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-allocations",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/allocations",
|
||||
content: supervisorAllocationsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "staff-my-groups",
|
||||
matches: pathname => pathname === "/dashboard/staff/my-groups",
|
||||
content: staffMyGroupsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-offline-scans",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/offline-scans",
|
||||
content: supervisorOfflineScansHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-waitlist",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/waitlist",
|
||||
content: supervisorWaitlistHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-consent",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/consent",
|
||||
content: supervisorConsentHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-coupons",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/coupons",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { QrCode, Filter, CheckCircle2 } from "lucide-react";
|
||||
import { QrCode, Filter, CheckCircle2, ShieldAlert, WifiOff } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -19,6 +19,12 @@ export const staffTicketScanningHelpContent: HelpContent = {
|
||||
<GuideItem icon={CheckCircle2} title="Outcomes" tone="green">
|
||||
You'll see Success (with redeemed/remaining counts), Already Used, or an Error if something's wrong with the ticket.
|
||||
</GuideItem>
|
||||
<GuideItem icon={WifiOff} title="Offline mode" tone="amber">
|
||||
Worried about the venue Wi-Fi? While you're online, pick the event and section and tap "Enable offline mode". The section is locked to your device and its tickets are downloaded, so you can keep scanning with no connection. Scans sync automatically when you're back online. Tickets bought after the download won't be on your list — send those people to the door desk. Tap "Release section" when you're done. Tip: add this page to your home screen so it opens offline.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ShieldAlert} title="Parental consent" tone="rose">
|
||||
On youth events, the confirm screen warns when a parent hasn't given consent yet (or declined). That person can't be checked in; a supervisor can override, and the override is recorded in the audit log.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { BedDouble, Layers, Move, Wand2, Printer, Ticket, Users } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for /dashboard/supervisor/allocations. */
|
||||
export const supervisorAllocationsHelpContent: HelpContent = {
|
||||
title: "Rooms & groups",
|
||||
subtitle: "Put attendees into rooms, cabins, small groups, teams or buses.",
|
||||
tabs: [
|
||||
{
|
||||
key: "page",
|
||||
label: "This page",
|
||||
icon: BedDouble,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Layers} title="Sets and groups" tone="brand">
|
||||
A set is one way of splitting people up, such as "Rooms", "Small groups" or "Buses". An event can have several sets, and each person can be in one group per set. Groups can have a capacity, be for males or females only, and have a leader (a staff member).
|
||||
</GuideItem>
|
||||
<GuideItem icon={Move} title="Drag and drop" tone="green">
|
||||
Drag people from "Not placed yet" into a group, between groups, or back again. A full group, or one for the other gender, won't take them. Allocation works per named person, so the event needs named attendees. Turn on "Ask each person's gender" if rooms are split by gender.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Wand2} title="Auto-allocate" tone="violet">
|
||||
Fills groups evenly with everyone not placed yet, respecting capacity and gender. "Keep bookings together" keeps people on the same booking (siblings) in one group where possible; "Split bookings" spreads them out (friends). People already placed stay where they are.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Printer} title="Lists" tone="amber">
|
||||
Print a list per group (with its leader) or one master list. On youth events you can include medical and emergency details from consent forms. Viewing those is recorded in the audit log.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Ticket} title="On tickets" tone="rose">
|
||||
Tick "Show on tickets" to print the group on each person's ticket and show it in My Events. To message one group ("Bus 2 leaves at 07:30"), pick it under Room / group on Email or WhatsApp attendees.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="Leaders" tone="gray">
|
||||
Group leaders see their people (with ages and parent contacts) under Staff → My groups. Medical details are only shown to supervisors.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Help content for /dashboard/staff/my-groups. */
|
||||
export const staffMyGroupsHelpContent: HelpContent = {
|
||||
title: "My groups",
|
||||
subtitle: "Rooms, small groups and buses you lead.",
|
||||
tabs: [
|
||||
{
|
||||
key: "page",
|
||||
label: "This page",
|
||||
icon: Users,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Users} title="Your people" tone="brand">
|
||||
Everyone a supervisor has placed in a group you lead, with their age and a contact number (theirs, a parent's, or the person who booked). Supervisors also see medical details here.
|
||||
</GuideItem>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -25,7 +25,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
|
||||
Per ticket-type totals with a stepper to check attendees in against the scan record.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Ticket} title="Tickets" tone="amber">
|
||||
Browse all tickets for the event; print or send (email/WhatsApp) any of them individually.
|
||||
Browse all tickets for the event; print or send (email/WhatsApp) any of them individually, or transfer one to someone else before the event starts (supervisors can transfer even when transfers are turned off for the event). Scanning a transferred ticket's old QR code says who it went to.
|
||||
</GuideItem>
|
||||
<GuideItem icon={RotateCcw} title="Refund" tone="rose">
|
||||
Issue a refund against an existing payment for the selected registration.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { ShieldCheck, Send, RefreshCw, HeartPulse, Ban, Clock, QrCode } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for /dashboard/supervisor/consent. */
|
||||
export const supervisorConsentHelpContent: HelpContent = {
|
||||
title: "Parental consent",
|
||||
subtitle: "Youth events: who still needs a parent's consent before check-in.",
|
||||
tabs: [
|
||||
{
|
||||
key: "how",
|
||||
label: "How it works",
|
||||
icon: ShieldCheck,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={ShieldCheck} title="Turning it on" tone="brand">
|
||||
In the event wizard's Visibility step, tick "Requires parental consent", write the consent text, and choose whether to ask for medical info, an emergency contact and photo consent.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Send} title="Requests" tone="green">
|
||||
Everyone registering gives a date of birth for each attendee. For anyone under 18 on the event's start date, the parent or guardian is sent a consent link by email and WhatsApp. They read the text, fill in any medical details, type their name to sign, and give or decline consent.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Clock} title="Reminders" tone="amber">
|
||||
Unanswered requests are reminded automatically 2 days later, then every 3 days until the event. Links stop working when the event ends.
|
||||
</GuideItem>
|
||||
<GuideItem icon={QrCode} title="At check-in" tone="rose">
|
||||
Tickets are sent as normal, but the scanner won't check someone in until consent is given. Supervisors can override, which is recorded in the audit log.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Ban} title="Declined" tone="gray">
|
||||
The registrant and the event's notification recipients are told. The registration stays, but can't be checked in — handle any refund from Payments.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
label: "This page",
|
||||
icon: RefreshCw,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Send} title="Resend" tone="brand">
|
||||
Resend one request, or every pending one at once. Each resend issues a new link; old links stop working.
|
||||
</GuideItem>
|
||||
<GuideItem icon={RefreshCw} title="Re-request consent" tone="violet">
|
||||
If you change the consent text, parents who already agreed keep their record of the old version. "Re-request consent" asks them again on the new text, and also sends requests to under-18s who never got one (for example, names added later).
|
||||
</GuideItem>
|
||||
<GuideItem icon={HeartPulse} title="Medical & emergency list" tone="rose">
|
||||
A printable, confidential list for camp leaders, for attendees whose consent has been given. Every time it's opened is recorded in the audit log. Medical details are deleted a set number of days after the event (Site Settings → Legal).
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user