Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30b567f091 | ||
|
|
0b22eca0fb | ||
|
|
5529811119 | ||
|
|
1f0b2b1425 | ||
|
|
865a16abe9 | ||
|
|
921b94a66a | ||
|
|
b3bfbe486c | ||
|
|
686616cc18 | ||
|
|
cd42ca375e | ||
|
|
ccb4db919a | ||
|
|
2b453b00e6 | ||
|
|
fb8ce5f86d | ||
|
|
4cd89b1a60 | ||
|
|
cc62f52ab3 | ||
|
|
238fcd6fd7 | ||
|
|
78b64329cc | ||
|
|
afcc14cbb3 | ||
|
|
1b92fae76c | ||
|
|
4c76c77005 | ||
|
|
93cff6099e | ||
|
|
7bcaec6284 | ||
|
|
2f4fd7c040 | ||
|
|
1516e104df | ||
|
|
e9588aeba8 | ||
|
|
83df90b531 | ||
|
|
87ffe3b84b | ||
|
|
9a4bf51ad4 | ||
|
|
d84663f901 | ||
|
|
be621c49a2 | ||
|
|
edf7c3052b | ||
|
|
f66968768f | ||
|
|
2166f94247 | ||
|
|
bae9675485 | ||
|
|
13fd3a4cbb | ||
|
|
809b6a11d7 | ||
|
|
f1e0b62c73 | ||
|
|
72ecf500c8 | ||
|
|
df0bf09a0e | ||
|
|
4b413788ac | ||
|
|
95dc3998ea | ||
|
|
709ea38c10 | ||
|
|
7ce125e36c | ||
|
|
96bfeff205 | ||
|
|
c3012cb3f6 | ||
|
|
0306c4dcb1 | ||
|
|
fc4e65b80a | ||
|
|
e52bf67015 | ||
|
|
e909f43fea | ||
|
|
889995e450 | ||
|
|
73fdce1879 | ||
|
|
7e661abd24 | ||
|
|
1f59916efa | ||
|
|
e728a06b9a | ||
|
|
1d9b87500d | ||
|
|
97faff1c89 | ||
|
|
5b2183677d | ||
|
|
af6dffe534 | ||
|
|
798156efe6 | ||
|
|
c79e0f2ce8 | ||
|
|
49b6ddc397 | ||
|
|
f3a2e812bf | ||
|
|
032d3c032e | ||
|
|
5a416916c9 | ||
|
|
44a9e0857c | ||
|
|
54b89d4f4b | ||
|
|
98ac26bf70 | ||
|
|
7e235637c4 | ||
|
|
0e8d5f93c9 | ||
|
|
a0ccce04a3 | ||
|
|
fbb84b037c | ||
|
|
2dfe8d32c4 |
@@ -34,6 +34,8 @@ Thumbs.db
|
||||
# misc scratch / generated files
|
||||
temp/
|
||||
backend/public/uploads/
|
||||
backend/backups/
|
||||
hope-events-deployment-setup.txt
|
||||
|
||||
# runtime data stores (mutated by the running app, not source)
|
||||
backend/data/scheduled-emails.json
|
||||
|
||||
+378
@@ -7,6 +7,384 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.25.1] - 2026-10-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- On mobile, the cookie consent banner now sits above the bottom navigation bar instead of being hidden behind it.
|
||||
- The floating help button moves up above the cookie consent banner while it is showing, so the banner no longer covers it.
|
||||
|
||||
## [1.25.0] - 2026-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- A **Yoco** tab in Site Settings for online payments:
|
||||
- **Secret key:** shown masked like a card number (`sk_test_***…aba`) with a Test/Live badge. Yoco checks a new key before it is saved.
|
||||
- **Webhook secret:** **Generate** registers this server's webhook URL (`BACKEND_URL/api/webhooks/yoco`, editable) with Yoco and saves the secret Yoco returns. **Enter manually** takes the secret of a webhook you registered yourself.
|
||||
- **Registered webhooks:** every webhook on the Yoco account, with its URL and mode. It shows which one is active and warns when a URL isn't this server. Each can be deleted; deleting the active one also removes its stored secret.
|
||||
- New admin endpoints under `/api/yoco` (config and webhooks). Changes are written to the admin audit log, without the secret values.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Yoco secret key and webhook secret are now stored in the database, AES-256-GCM encrypted, instead of only in `backend/.env`. `YOCO_SECRET_KEY` and `YOCO_WEBHOOK_SECRET` still work as fallbacks when the database has no value. `npm run migrate:env-to-db` moves them across.
|
||||
- Yoco secrets are never returned by `/api/settings/all` and can't be changed through `PUT /api/settings`.
|
||||
- Starting a checkout or sponsorship without a Yoco key now gives a clear "online payments are not configured" message instead of failing at Yoco.
|
||||
|
||||
## [1.24.0] - 2026-10-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Ticket transfers now need the new holder to accept.
|
||||
- "Transfer" (in My Events, or At the door → Tickets) sends the person a link by email and/or WhatsApp, to the details typed.
|
||||
- Nothing changes on the ticket until they accept on the new `/transfer/<link>` page. They can also decline.
|
||||
- On accepting, the transfer completes as before: new QR code, ticket delivery, account activation link, parental consent on youth events.
|
||||
- My Events shows "Waiting for … to accept" with a Cancel button.
|
||||
- Offers expire after 7 days or when the event starts, and a new offer replaces an open one.
|
||||
- Privacy for group tickets and transfers: there is no user search, and nothing reveals whether an email or phone number belongs to an account.
|
||||
- Registration responses no longer include named attendees' account links.
|
||||
- If an email and phone point at two different accounts, this is no longer reported to the person typing them. The email's account is used.
|
||||
- Terms of Use and Privacy Policy updated for everything added since 1.12:
|
||||
- booking for other people, ticket transfers, and youth-event consent with health information and how long it is kept;
|
||||
- who can see what (door staff, group leaders, supervisors), and read-only admin support access;
|
||||
- sponsorships, waitlists, discount codes and feedback;
|
||||
- two-factor authentication and offline scanning.
|
||||
- The Privacy Policy has a full **cookie policy**: every item the site stores in the browser (necessary, staff-only and optional), the optional visit statistics, and the third-party cookies from the Google Maps embed and Yoco checkout.
|
||||
- A new "Cookie settings" link in the footer (and a button in the policy) lets visitors change their choice.
|
||||
- The API docs page (`/docs`) now lists every mounted route. 45 were missing, including dashboard stats, backups, attachments, the audit log, ticket transfer links and WhatsApp admin.
|
||||
|
||||
### Fixed
|
||||
|
||||
- After activating an account, the visitor is now really signed in. The token was saved under a key the app never read, so the "auto-login" didn't work.
|
||||
|
||||
### Notes
|
||||
|
||||
- `npm install` succeeds in the root, `backend` and `frontend` with no missing packages.
|
||||
- The remaining `npm audit` findings are all in development/build tools (Jest, nodemon, ESLint, Tailwind's build), and none affect the running site.
|
||||
- `braces` and `sprintf-js` have no patched release.
|
||||
- `postcss-selector-parser` 7 would break Tailwind 3.
|
||||
- Requires the `add_transfer_requests` database migration.
|
||||
|
||||
## [1.23.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Impersonate user (Admin → Manage users → Impersonate): an admin can see the site exactly as a user does, to help with problems such as "my ticket isn't showing".
|
||||
- A reason is required, and the session lasts at most 30 minutes.
|
||||
- A bright "Viewing as [name]" banner with a countdown and "End impersonation" shows on every page.
|
||||
- Impersonation is strictly read-only.
|
||||
- A server-side guard, mounted before every route, refuses any change (registrations, payments, refunds, cancellations, profile, consent, messages and everything else) made with an impersonation token.
|
||||
- Viewing pages doesn't trigger the price refresh it normally does.
|
||||
- Admins can't be impersonated, and the admin must have two-factor authentication on.
|
||||
- Impersonation is recorded in the audit log: start, end, reason, and every page and API call, including blocked attempts.
|
||||
- The audit log page lists recent sessions with their full trail.
|
||||
- New setting: email users when an admin views their account (Site Settings → Legal → Support access; off by default).
|
||||
- New `/api/admin/impersonate` endpoints.
|
||||
- Requires the `add_impersonation` database migration.
|
||||
|
||||
## [1.22.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Registration funnel stats (first-party, no third-party analytics), showing where people drop off between seeing an event and paying.
|
||||
- A new cookie notice asks visitors once. Only those who choose "Allow" are counted, using a random anonymous visit id.
|
||||
- We record the event page view and the start of registration. The submit and paid stages come from the registration itself.
|
||||
- Only the stage, the traffic source and the device type are stored. No name, IP address or User-Agent is kept.
|
||||
- New report, **Registration funnel**: counts and conversion % per stage over a date range, a daily trend, and breakdowns by traffic source, device and ticket option.
|
||||
- Selecting several events compares them.
|
||||
- Sources come from `utm_source` links and from short links, which now add `?src=<alias>` when redirecting.
|
||||
- A "Conversion (past month)" tile on the supervisor and admin dashboards covers live events.
|
||||
- Raw visit records are rolled up into daily totals after 90 days by a daily job (`ANALYTICS_ROLLUP_ENABLED=false` to turn it off).
|
||||
- The privacy policy's cookies section now describes these statistics.
|
||||
- New `/api/analytics` endpoints.
|
||||
- Requires the `add_funnel_analytics` database migration.
|
||||
|
||||
## [1.21.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Post-event feedback, set up in a new Feedback step in the event wizard.
|
||||
- Questions use the form builder.
|
||||
- You pick when it's sent (the default is the morning after the event) and who gets it: everyone who checked in, or everyone registered.
|
||||
- Each person gets a personal link by email (and WhatsApp where they use it).
|
||||
- Anyone who hasn't answered gets one reminder 3 days later.
|
||||
- Answers can be anonymous or named.
|
||||
- "Send now" is available, and the questions lock once people start answering.
|
||||
- New form field types: **Rating (1–5 stars)** and **Multiple choice**. They're used for feedback, and can also go on attendee forms (registration page, My Events, the kiosk, manual registration and Supervisor → Forms).
|
||||
- New report, **Feedback summary**:
|
||||
- response rate;
|
||||
- average rating per question, with star distribution;
|
||||
- multiple-choice and yes/no breakdowns as bar charts;
|
||||
- a list of text answers (with names unless the form is anonymous).
|
||||
- Select several events to compare their ratings (e.g. Camp 2025 vs Camp 2026). Exports to PDF and Excel like other reports.
|
||||
- New `/api/feedback` endpoints and a job every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to turn it off).
|
||||
- Requires the `add_feedback_forms` database migration.
|
||||
|
||||
## [1.20.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Sponsor a spot: a new event setting, "Allow sponsorships" (event wizard → Sponsor a spot).
|
||||
- It adds a "Sponsor a spot" button on the event page so people can fund a place for someone who can't afford it.
|
||||
- Sponsors choose one or more whole spots (at the main ticket price) or a custom amount, and pay through Yoco. Guests just give a name and email.
|
||||
- Sponsors can give anonymously, or add a name and a message for the organisers.
|
||||
- An optional goal ("12 of 20 spots sponsored") and public progress bar.
|
||||
- Each sponsorship is recorded as a donation tagged to the event, in that event's sponsorship pool.
|
||||
- Supervisors assign pool money to registrations with the existing donation assign/unassign flow, fully or partly.
|
||||
- Under Payments → Donations, sponsorships are labelled, there's an "Only the sponsorship pool" filter, and the sponsor's message is shown.
|
||||
- Who receives help is never shown to sponsors.
|
||||
- Sponsors get a "Thank you for sponsoring a spot" receipt.
|
||||
- Automations has a new "Thank Your Sponsors" email for events with sponsorships.
|
||||
- Email attendees can target an event's sponsors (`filter.audience: 'sponsors'`).
|
||||
- The Donations breakdown report has sponsorship pool columns per event: received, assigned and remaining.
|
||||
- New `/api/sponsorships` endpoints.
|
||||
- Requires the `add_sponsorships` database migration.
|
||||
|
||||
## [1.19.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Rooms & groups (Supervisor → Rooms & groups), for camps and lockdowns: put attendees into rooms, cabins, small groups, teams or buses.
|
||||
- Each event can have several allocation sets (e.g. "Rooms" and "Buses"). Each set has groups with an optional capacity, an optional gender restriction and an optional leader (a staff member).
|
||||
- A drag-and-drop board puts people not yet placed on the left and groups, with capacity counters, on the right. Full groups and groups for the other gender won't accept a person.
|
||||
- Auto-allocate fills groups evenly with everyone not yet placed, respecting capacity and gender. It can keep people on the same booking together or spread them out.
|
||||
- Printable lists per group (with leader) and a master list. On youth events they can include medical and emergency details from consent forms; viewing those is recorded in the audit log.
|
||||
- "Show on tickets" prints the group on each person's ticket and shows it in My Events.
|
||||
- Email and WhatsApp attendees can be sent to one group ("Bus 2 leaves at 07:30"). Messages go to whoever booked for the people in it.
|
||||
- Group leaders see their people under Staff → My groups. Medical details are shown to supervisors only.
|
||||
- New event setting "Ask each person's gender" (under Named attendees). It adds a gender choice for each named person on the registration page, at the door, on the kiosk, in manual registration and in My Events, and is needed for gender-split rooms.
|
||||
- New `/api/allocations` endpoints.
|
||||
- Requires the `add_room_allocation` database migration.
|
||||
|
||||
## [1.18.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Ticket transfer. On My Events, "Transfer" hands a ticket to someone else before the event: enter their name and email or phone.
|
||||
- The new holder gets the ticket by email/WhatsApp with a new QR code. If they're new, an inactive account is created and they get an activation link.
|
||||
- The old QR code stops working, so screenshots of it won't scan. Scanning it says who the ticket was transferred to, and offline-mode sync flags it.
|
||||
- For a ticket covering several people, one place is split off to the new holder.
|
||||
- The payer stays responsible for the registration.
|
||||
- Transfer rules:
|
||||
- Only before the event starts, and only for tickets that haven't been scanned.
|
||||
- It can be turned off per event (new "Ticket transfer" setting in the event wizard, on by default). Supervisors can always transfer, from At the door → Tickets.
|
||||
- On youth events, the new holder's date of birth, and a parent's details for a minor, are needed, and a new consent request is sent.
|
||||
- The previous holder's attendee form answers and consent medical details are removed.
|
||||
- Every transfer is recorded in the audit log.
|
||||
- New endpoints `POST /api/tickets/:id/transfer` and `GET /api/tickets/:id/transfers`.
|
||||
- Requires the `add_ticket_transfer` database migration.
|
||||
|
||||
## [1.17.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Offline ticket scanning. While online, staff pick an event and section on the scanner and tap "Enable offline mode". The section (or the whole event) is locked to their device and its tickets are downloaded to the device.
|
||||
- With no connection, scans are checked against that list and queued on the device.
|
||||
- The panel shows when the list was downloaded and how many scans are waiting.
|
||||
- Queued scans sync automatically when the device is back online (or with "Sync now"), and check-in messages go out then.
|
||||
- "Release section" ends offline mode and deletes the list from the device.
|
||||
- Only one device can hold a section at a time (a whole-event lock clashes with every section). While a section is locked, other devices see who holds it and can't scan it, online or offline, so a ticket can never be redeemed twice by two offline devices.
|
||||
- Syncing is idempotent and never redeems more than is left on a ticket. Anything that needs a person to look goes into a sync report: tickets not found, cancelled registrations, used-up tickets, scans outside the locked section, and check-ins without consent.
|
||||
- Supervisor → Offline scanning page: active locks with force release (for a lost or flat phone; its scans still sync if it reconnects, and are flagged), and sync reports to review. Locks, releases, force-releases and syncs are recorded in the audit log.
|
||||
- The scanner is installable as an app (web app manifest), and a service worker caches the scanner page and its scripts so it opens with no connection.
|
||||
- The app now stays signed in with the last confirmed profile when it's reloaded without a connection, instead of sending the user to the login page.
|
||||
- New `/api/scan-locks` endpoints.
|
||||
- Requires the `add_offline_scanning` database migration.
|
||||
|
||||
### Changed
|
||||
|
||||
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and checks it on the server; this was previously only checked in the browser.
|
||||
- It now refuses (423) scans in a section that another device has locked for offline scanning.
|
||||
|
||||
## [1.16.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Waitlist for sold-out tickets. A new event setting (event wizard → Visibility) turns it on, with how long offered places are held (24 hours by default) and whether someone can be offered fewer places than they asked for.
|
||||
- Sold-out tickets show "Join waitlist" on the event page, the register page and the events list. Guests can join on events that allow guest registration.
|
||||
- When places free up, the next person is emailed/WhatsApped a claim link, and the places are held for them so nobody else can buy them. Places free up through a cancellation (by the person or staff), a smaller booking, or a raised stock limit.
|
||||
- The claim link opens the register page with the places pre-selected, and works without logging in.
|
||||
- Unclaimed offers expire (checked every 10 minutes) and move on to the next person.
|
||||
- People can leave a waitlist from My Events, which now lists their waitlists and any places held for them.
|
||||
- Supervisor → Waitlist page: the queue per event, with offer now (even over the stock limit), move up/down and remove.
|
||||
- The daily summary email shows each event's waitlist count.
|
||||
- New `/api/waitlist` endpoints. `POST /api/registrations` accepts `waitlistToken`.
|
||||
- Requires the `add_waitlist` database migration.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Manual registrations (Manual registration, At the door, the kiosk) and adding places to an existing booking never checked stock, so either could take an option over its stock limit. Both are now checked like a normal registration.
|
||||
- Stock is now counted in one place (`utils/stock.js`) for registration checks, `availableCount` and `isSoldOut`, including places held for waitlist offers.
|
||||
|
||||
## [1.15.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Parental consent for youth events. A new "Requires parental consent" event setting (event wizard → Visibility) has its own consent text and optional medical info, emergency contact and photo consent questions. It turns on named attendees automatically.
|
||||
- Registering then needs a name and date of birth for every main-ticket place. Anyone under 18 on the event's start date needs a parent or guardian's name, email and phone.
|
||||
- This applies on the registration page, manual registration, at the door, the kiosk, and when editing names in My Events.
|
||||
- The parent gets an email and a WhatsApp (always both) with a link to a new consent page (`/consent/<token>`). There they read the text, answer the optional questions, type their full name and confirm they're the parent or guardian, then give or decline.
|
||||
- The system records the time, IP, device, signed name, and the exact consent text and version shown.
|
||||
- Medical and emergency details are stored encrypted.
|
||||
- The registrant and the event's notification recipients are told the outcome.
|
||||
- Tickets are still issued, but the scanner blocks check-in until consent is given, showing why. Supervisors can override, and the override is recorded in the audit log. On a youth event, a ticket with no named attendee counts as missing consent.
|
||||
- A daily 09:00 job reminds unanswered requests after 2 days, then every 3 days until the event. It also deletes medical and emergency details a set number of days after the event (Site Settings → Legal, default 30). `CONSENT_JOBS_ENABLED=false` turns it off.
|
||||
- Supervisor → Parental consent page: consent status per attendee, resend one or all pending requests, re-request consent after the consent text changes, and a printable, confidential medical & emergency list. Every view of the list is recorded in the audit log.
|
||||
- New reports: "Parental consent status" and "Medical & emergency list".
|
||||
- My Events shows each attendee's consent status, with a "Resend consent" button.
|
||||
- Anonymising or closing an account also clears that person's attendees' parent, date-of-birth, medical and emergency details.
|
||||
- New endpoints under `/api/consent`. `GET /api/tickets/scan-preview/:qrCode` now returns `consentStatus`, and `POST /api/tickets/scan/:qrCode` accepts `overrideConsent`.
|
||||
- Requires the `add_parental_consent` database migration.
|
||||
|
||||
## [1.14.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Group tickets (named attendees). A new "Named attendees" event setting (event wizard → Visibility) lets someone registering several people give each main-ticket place a name, with an optional email and phone. It's offered on the registration page, Manual registration, At the door and the self-service kiosk.
|
||||
- Each named person gets their own single-person ticket, with their name on the ticket PDF, the scanner's confirm screen and the staff ticket list. Places left unnamed share one quantity ticket as before.
|
||||
- The payer chooses whether every ticket comes to them or each person gets their own by email/WhatsApp; anyone without contact details falls back to the payer.
|
||||
- The payer stays the account owner for billing, invoices and payments.
|
||||
- An attendee whose email or phone matches an existing account is linked to it (never when the email and phone belong to two different accounts). Their ticket then appears in their own My Events, marked "Booked by …", and they can open and submit their own attendee form.
|
||||
- My Events → registration popup has a new "People" section for adding or changing names and contact details, switching ticket delivery, and sending an attendee a link to fill in their own form (`/forms?registrationId=…&attendeeId=…`). An attendee whose ticket has been scanned can't be removed.
|
||||
- Attendee forms now show one block per named person, labelled with their name, and each response is saved against that attendee.
|
||||
- New endpoints `PUT /api/registrations/:id/attendees` and `POST /api/registrations/:id/attendees/:attendeeId/send-form-link`. `POST /api/registrations` and `/manual` accept `attendees` and `ticketDelivery`.
|
||||
- Requires the `add_named_attendees` database migration.
|
||||
|
||||
### Changed
|
||||
|
||||
- Reducing the quantity of a main ticket removes the newest names beyond the new quantity.
|
||||
- Check-in confirmations for a named ticket go to that person when the registration sends each person their own tickets.
|
||||
- Unauthenticated guest registration now matches an existing account by phone as well as email, and is refused (409) when the email and phone belong to two different accounts, instead of silently reusing whichever account the email matched.
|
||||
- Free registrations now issue tickets through the same generator as paid ones, instead of a separate copy of the logic.
|
||||
|
||||
### Security
|
||||
|
||||
- Registration details (`GET /api/registrations/:id`) hide attendees' email addresses and phone numbers from anyone other than the payer and staff, such as an attendee opening their form link.
|
||||
|
||||
## [1.13.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Coupon codes. Supervisors create percentage, fixed-amount or free ("comp") codes under Supervisor → Coupon codes.
|
||||
- Codes can be limited to particular events, ticket options or variants, a date window, total and per-person uses (one per person by default), a minimum number of tickets, and specific people, roles (e.g. Staff and above) or an email domain. Restricted codes need a logged-in account, so they can't be shared around.
|
||||
- By default the better of the code and the early-bird price wins; a code can instead stack on top of early-bird pricing.
|
||||
- People enter codes under "Have a code?" on the registration page and in My Events (for registrations not yet paid in full). Staff can apply them in Manual registration, At the door and the self-service kiosk.
|
||||
- The discount is locked in when it's applied, the same way early-bird prices are. It appears as its own line on invoices, receipts, registration and payment emails, WhatsApp messages and the Yoco checkout.
|
||||
- Editing a registration's items recalculates percentage and free codes; fixed-amount codes keep their amount. Cancelling a registration frees the use. A free code that covers everything marks the registration paid and sends the tickets.
|
||||
- Use limits are enforced under a database lock, so the last available use can't be claimed twice.
|
||||
- Staff price: a ticket option or variant can carry a staff price (event wizard → Items & pricing) that Staff and above pay automatically when it's lower than the normal price.
|
||||
- New "Discounts (coupon codes)" report (Reports → Financial), showing uses and value given away per code and event. The Finance report's "What was sold" is now shown after discounts.
|
||||
- Coupon activity in the admin audit log: codes created, edited and turned off, and codes applied or removed by staff.
|
||||
- New endpoints: `POST /api/coupons/validate`, `GET|POST /api/coupons`, `GET|PUT /api/coupons/:id`, `GET /api/coupons/redemptions`, `POST|DELETE /api/registrations/:id/coupon`. `POST /api/registrations` and `/manual` accept `couponCode`.
|
||||
- Registration responses now include `discountTotal` and `subtotalComputed`.
|
||||
- Requires the `add_coupon_codes` database migration (`npm run prisma:deploy` in `backend`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Yoco checkout line items priced each item at its most recent price times the whole quantity. When a line spanned several price tranches (some tickets at early-bird, some at full price), the item lines didn't match the amount charged. They now list each price actually charged.
|
||||
- The free-registration check on `POST /api/registrations` and `/manual` had the same fault: it used the latest price for the whole quantity instead of the real total.
|
||||
|
||||
## [1.12.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Two-factor authentication is now required for Admin and Supervisor accounts. An admin or supervisor without it is sent a code at their next login (by email, or WhatsApp when SMTP isn't set up), and entering it turns two-factor on — the same code step as before, with a note explaining why. Trusted devices don't skip this first code. They can change whether codes come by email or WhatsApp from Profile & Security, but can no longer turn two-factor off (`POST /api/users/2fa/disable` returns 403 for them).
|
||||
- Existing admin and supervisor sessions without two-factor are ended on their next request (`401` with `code: "TWO_FACTOR_REQUIRED"`), and the login page explains why. Promoting a user to supervisor or admin also ends their sessions if they don't have two-factor yet, so they enrol at their next login. An admin "Reset 2FA" for a supervisor/admin now means they set it up again at their next login.
|
||||
- The requirement is skipped when no channel can deliver a code — no SMTP configured and no WhatsApp — so a fresh install (or the setup wizard, which also gets a short grace period) never locks the first admin out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off as an emergency switch.
|
||||
|
||||
- `FEATURES.md`, a repo-root overview of everything the platform does — roles, public site, events, registration, payments, tickets, messaging, dashboards, cashup, reports, settings, security, background jobs, and a page map.
|
||||
|
||||
### Changed
|
||||
|
||||
- `info.md` (the overview for prospective churches) brought up to date with features added since it was written — donations, invoices and receipts, cashup and costs, profit reporting, audit trail, scheduled messaging, self-service kiosk, scan points, backups, branding, and privacy controls.
|
||||
|
||||
## [1.11.0] - 2026-10-08
|
||||
|
||||
### Added
|
||||
|
||||
- Optional two-factor authentication. Any user can turn it on from Profile & Security and choose to receive a 6-digit code by email or WhatsApp; the chosen channel is confirmed with a code before it takes effect. At login the code is asked for after the password, expires after 10 minutes and allows 5 attempts, with a "Resend code" option. If a WhatsApp code can't be sent, it falls back to the account's email address.
|
||||
- "Trust this device for 30 days" on the code step, so a remembered device skips the code. Trusted devices are forgotten when the password is changed or reset, when sessions are signed out everywhere, and when two-factor is turned off.
|
||||
- Admins can reset two-factor authentication for a user who can no longer receive codes ("Reset 2FA" on the Users page). Turning two-factor on or off shows in Account activity.
|
||||
- The self-service kiosk asks supervisors with two-factor on for their code at setup. Changing the event now re-confirms the password against the existing kiosk session (`POST /api/users/verify-password`) instead of logging in again.
|
||||
|
||||
### Changed
|
||||
|
||||
- `POST /api/users/login` returns `{ twoFactorRequired, challengeId, method, destination }` instead of a token for accounts with two-factor on; the token is issued by the new `POST /api/users/login/2fa`. Accounts without two-factor are unaffected. Requires the `add_two_factor` database migration (`npm run prisma:deploy` in `backend`).
|
||||
|
||||
## [1.10.6] - 2026-10-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- The frontend production build (`next build`) no longer hangs or fails when the backend isn't reachable. With no `NEXT_PUBLIC_API_URL`/`API_URL` set, the root layout fetched a relative `/api/settings` URL that never settled during prerender, so every page timed out after 60 seconds; server-side fetches now fail fast when no API base is configured. The home page and the Events page also no longer fail the build when `/api/events` can't be reached — they build with an empty list and fill in on the first revalidation. At runtime a failed fetch still errors, so the last good page keeps being served.
|
||||
|
||||
### Security
|
||||
|
||||
- Updated dependencies to clear `npm audit` advisories: `express`, `multer`, `proxy-addr`, `qs` and others in the backend, `next` (15.5.27) and `sharp` (0.35.5) in the frontend, and `nodemailer` from 9 to 10 (requires Node.js 20 or newer). The root package now overrides `shell-quote` to a patched release, since every `concurrently` version pins a vulnerable one. Backend production dependencies now report no known vulnerabilities; the remaining advisories are in development tooling (`nodemon`, `jest`, `tailwindcss` 3) and have no non-breaking fix.
|
||||
- Dependency install scripts are now explicitly allow-listed via `allowScripts` in the backend and frontend `package.json` (Prisma, `@sentry/cli`, `unrs-resolver`), so npm no longer blocks them.
|
||||
|
||||
## [1.10.5] - 2026-10-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Events that have already started but not yet finished now show on the home page and the Events page. Both pages were hiding an event as soon as its start time passed; they now keep it listed until its end time, which matches what `GET /api/events` already returns.
|
||||
|
||||
## [1.10.4] - 2026-09-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- "Add to calendar" (`GET /api/events/:id/ics`) always failed with a 400 in production: the `ics` library's `url` field requires a scheme (`https://...`), but `FRONTEND_URL`/`APP_BASE_URL` was configured without one, so `createEvent()` rejected it before generating any output. A missing scheme is now assumed to be `https://` instead of failing.
|
||||
|
||||
## [1.10.3] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Creating an event was never recorded in the admin audit log — 1.10.0 added `event_created` as a logged action on the frontend's filter list, but the backend's `createEvent` never actually called `logAdminAction`, only `updateEvent`/`deleteEvent` did. Event creation is now logged the same way.
|
||||
- Staff cancelling a registration via `PUT /api/registrations/:id` (the staff status-change endpoint, separate from the owner-facing `DELETE /:id` cancel route) wasn't logged at all — only the `DELETE` path logged `registration_cancelled`. Both paths now log it, verified against all six audit categories promised in 1.10.0 (refunds, donation assign/unassign, manual registrations, staff-initiated cancellations, event create/update/delete, settings changes) with no other gaps found.
|
||||
- Added a "Back to dashboard" link to Admin → Audit log, matching the back-link pattern already used on the Cashup page.
|
||||
|
||||
## [1.10.2] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sentry wasn't instrumenting Express (`[Sentry] express is not instrumented` at startup): `express`, `cors`, and `@prisma/client` were required at the top of `backend/src/index.js` before `Sentry.init()` ran, but Sentry's auto-instrumentation patches those modules via a require hook that only works if `Sentry.init()` runs first. `Sentry.init()` now runs immediately after `dotenv.config()`, before any of the libraries it instruments are required.
|
||||
|
||||
## [1.10.1] - 2026-08-28
|
||||
|
||||
### Security
|
||||
|
||||
- Fixed a path-traversal vulnerability in event-image, branding (logo/favicon), and event-attachment uploads: the stored filename embedded the client-supplied `originalname` unsanitized, so a crafted filename (e.g. containing `../`) could write the uploaded file outside the intended `public/uploads` subfolder anywhere the server process could write. Uploaded files are now always saved under a server-generated random name; the original filename is preserved only as display metadata.
|
||||
- `express-rate-limit` was declared as a root-only dependency despite being required directly by the backend (`backend/src/index.js`) — a plain `cd backend && npm install`, as documented in the deployment guide, would not have installed it. It's now a proper `backend/package.json` dependency.
|
||||
- Bumped `next` (frontend) off a version affected by a critical unauthenticated RCE in the React Flight protocol (GHSA-9qr9-h5gf-34mp) and several other CVEs, and switched it from an exact pin to `^15.5.24` so future patch releases install automatically.
|
||||
- Bumped `multer`, `nodemailer`, `jsonwebtoken`, and `uuid` (backend) to versions fixing DoS, SMTP/CRLF-injection, HMAC-verification, and buffer-bounds advisories; added an `overrides` entry so the vulnerable `uuid` nested under `exceljs` is also patched. Ran `npm audit fix` across all three workspaces (root/backend/frontend) — 0 known vulnerabilities remain.
|
||||
- `jwt.verify()` now pins `algorithms: ['HS256']` explicitly rather than trusting the algorithm from the token header.
|
||||
- Uploaded assets served from `/uploads` now get `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff`, so an uploaded SVG containing a `<script>` can no longer execute if opened directly.
|
||||
|
||||
## [1.10.0] - 2026-08-28
|
||||
|
||||
### Added
|
||||
|
||||
- Event detail pages now have an "Add to calendar" button, and registration confirmation emails include an "Add to calendar" link (requires `BACKEND_URL` to be set), both downloading a `.ics` file for the event.
|
||||
- SEO: the site now serves a `sitemap.xml` (every public page plus every event) and `robots.txt`, and event/event-list pages have proper Open Graph/Twitter metadata for link previews.
|
||||
- Error monitoring via Sentry — set `SENTRY_DSN` (backend) and `NEXT_PUBLIC_SENTRY_DSN` (frontend) to enable; a no-op otherwise. Captures unhandled backend errors/promise rejections and frontend errors.
|
||||
- Nightly local database backups (`pg_dump`, gzipped, 02:00, 14-day retention by default) with a new Site Settings → Backups tab to view, manually trigger, and download them. Requires `pg_dump` to be installed on the app server; set `BACKUP_ENABLED=false` to disable. Local-disk only — not uploaded anywhere else.
|
||||
- Admin audit trail: refunds, donation assign/unassign, manual registrations, staff-initiated cancellations, event create/update/delete, and settings changes are now logged with who/when/what, viewable at Admin → Audit log.
|
||||
- Starter backend test suite (Jest) covering the early-bird tranche pricing logic and donation-balance calculation — the two areas with the most complex money-handling history in this changelog.
|
||||
|
||||
## [1.9.5] - 2026-08-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- The public event-alias route (`/:redirectUrl`, used for short links like `/camp-2025`) is a catch-all matching any unmatched top-level path, so every bot/scanner probe for a nonexistent page (`/wp-login.php`, `/.env`, etc.) was hitting the backend and firing a live database query. Both the frontend route and the `GET /api/events/by-alias/:redirectUrl` endpoint now reject anything that isn't a plausible alias (letters/numbers/hyphens/underscores) before touching the database, instead of forwarding scanner noise straight through — this traffic pattern could exhaust the database connection pool and take the server down under load.
|
||||
|
||||
### Added
|
||||
|
||||
- PM2 (`ecosystem.config.js`) now restarts either process if it exceeds 500MB of memory, instead of letting an unbounded leak run until the OS OOM-kills it.
|
||||
|
||||
## [1.9.4] - 2026-08-26
|
||||
|
||||
### Added
|
||||
|
||||
- New `TRUST_PROXY` backend env var — set it when the app runs behind a reverse proxy (e.g. nginx on a separate server) so rate limiting reads the real client IP from `X-Forwarded-For` instead of the proxy's. Accepts a hop count, `true`/`false`, or trusted proxy IP(s)/CIDR(s).
|
||||
|
||||
## [1.9.3] - 2026-08-26
|
||||
|
||||
### Fixed
|
||||
|
||||
+549
@@ -0,0 +1,549 @@
|
||||
# Hope Events — Feature Overview
|
||||
|
||||
A complete map of what the platform does, grouped by who uses it. For setup and
|
||||
deployment see [README.md](./README.md); for the API see
|
||||
[backend/API_DOCUMENTATION.md](./backend/API_DOCUMENTATION.md); for setting
|
||||
defaults see [SETTINGS.md](./SETTINGS.md); for history see
|
||||
[CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
**Stack:** Next.js (React, Tailwind, shadcn/ui) frontend · Express + Prisma +
|
||||
PostgreSQL backend · Yoco for card payments · SMTP for email · WAWP for
|
||||
WhatsApp · PM2 for process management · optional Sentry for error monitoring.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Roles](#roles)
|
||||
2. [Public site](#public-site)
|
||||
3. [Events](#events)
|
||||
4. [Registration](#registration)
|
||||
5. [Payments, donations & refunds](#payments-donations--refunds)
|
||||
6. [Tickets & check-in](#tickets--check-in)
|
||||
7. [Attendee forms](#attendee-forms)
|
||||
8. [Messaging & notifications](#messaging--notifications)
|
||||
9. [User dashboard (My Events)](#user-dashboard-my-events)
|
||||
10. [Staff tools](#staff-tools)
|
||||
11. [Supervisor tools](#supervisor-tools)
|
||||
12. [Admin tools](#admin-tools)
|
||||
13. [Cashup & event costs](#cashup--event-costs)
|
||||
14. [Reports](#reports)
|
||||
15. [Site settings & setup wizard](#site-settings--setup-wizard)
|
||||
16. [Accounts & security](#accounts--security)
|
||||
17. [Help system](#help-system)
|
||||
18. [Background jobs](#background-jobs)
|
||||
19. [Operations](#operations)
|
||||
20. [Page map](#page-map)
|
||||
|
||||
---
|
||||
|
||||
## Roles
|
||||
|
||||
Four roles, each including everything the roles below it can do:
|
||||
|
||||
| Role | Can do |
|
||||
|------|--------|
|
||||
| **User** | Browse events, register, pay, donate, view tickets, fill in forms, manage their own profile |
|
||||
| **Staff** | Scan tickets, view/print event tickets, view registrations and form responses |
|
||||
| **Supervisor** | Create and manage events, record payments/refunds, assign donations, at-the-door desk, manual registration, bulk email/WhatsApp, reports, site banner, self-service kiosk |
|
||||
| **Admin** | Manage users and roles, all registrations, event costs and cashup (close/reopen events), audit log, site settings, WhatsApp connection, backups |
|
||||
|
||||
---
|
||||
|
||||
## Public site
|
||||
|
||||
- **Home page** — hero section with the organisation's name and branding, upcoming
|
||||
and in-progress events, and a "Join Us" button (becomes "My Dashboard" when
|
||||
logged in).
|
||||
- **Events list** (`/events`) — event cards with picture, dates, location,
|
||||
price, and a Register / Sold Out / Contact us button.
|
||||
- **Event detail** (`/events/[id]`) — description, ticket options with
|
||||
variant and early-bird pricing, low-stock and sold-out badges, downloadable
|
||||
attachments, embedded Google Map with "Directions" link, and an
|
||||
**Add to calendar** (`.ics`) button.
|
||||
- **Short links** — events can have an alias (e.g. `/camp-2025`) that redirects
|
||||
to the event. Only plausible aliases hit the database, so bot probes are
|
||||
rejected cheaply.
|
||||
- **Contact page** (`/contact`) — organisation email, phone, address and map.
|
||||
- **Legal pages** — Terms of Use and Privacy Policy, filled from Site Settings.
|
||||
- **Site banner** — a site-wide announcement bar set by supervisors.
|
||||
- **Lockdown rules** (`/lockdown-rules`) — static rules page for the youth
|
||||
lockdown event.
|
||||
- **SEO** — `sitemap.xml` (every public page and event), `robots.txt`, and
|
||||
Open Graph/Twitter link-preview metadata.
|
||||
- **Theming** — brand colours, logo and favicon come from Site Settings.
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
Created and edited by supervisors through a 6-step wizard: **Basic Details →
|
||||
Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
|
||||
- **Basic details** — title, description, start/end dates, registration
|
||||
deadline, picture (upload or URL), location (defaults to the org's address),
|
||||
contact person.
|
||||
- **Contact-only events** — for things like baptisms: shown publicly with a
|
||||
"Contact us" popup instead of registration, with no tickets.
|
||||
- **Ticket options** — multiple options per event (e.g. Adult, Child, Dinner,
|
||||
T-shirt), each with a price and optional **stock limit**. One option is marked
|
||||
as the main ticket, which is what gets checked in at the door.
|
||||
- **Variants** — sub-types of an option (sizes, colours, Adult/Child/VIP), each
|
||||
with an optional price override and its own stock limit.
|
||||
- **Early-bird tiers** — deadline- and/or stock-limited cheaper prices per
|
||||
option or variant. Prices are locked in per purchase ("tranches"), so buying
|
||||
more later doesn't re-price what was already bought.
|
||||
- **Sections** — named scan points (e.g. "Main hall", "Dinner") limited to
|
||||
certain ticket options, so a scanner at that point only accepts those tickets.
|
||||
- **Visibility** — active/inactive, hidden (reachable only by link), scheduled
|
||||
go-live date, require login vs. allow guest registration, or no registration
|
||||
at all.
|
||||
- **Attachments** — downloadable files (maps, schedules, waivers) shown on the
|
||||
event page.
|
||||
- **Notify recipients** — choose which staff get registration/payment alerts and
|
||||
the daily summary for the event.
|
||||
- **Sold-out handling** — an event is sold out when every stock-limited option
|
||||
is exhausted. "X remaining" badges appear at a threshold that scales with
|
||||
capacity.
|
||||
|
||||
---
|
||||
|
||||
## Registration
|
||||
|
||||
- **Online registration** (`/register/[eventId]`) — pick options, variants and
|
||||
quantities; see early-bird prices and stock warnings; fill in the event form.
|
||||
- **Guest registration** — when an event doesn't require login, an inactive
|
||||
account is created and an activation link is sent by email or WhatsApp.
|
||||
- **Statuses** — Pending, Confirmed, Partially Paid, Paid, Cancelled.
|
||||
- **Edit options** — users can add or change items on their registration
|
||||
(not for past or closed events).
|
||||
- **Self-cancellation** — allowed only before any payment; admins can cancel
|
||||
any time.
|
||||
- **Pricing refresh** — when an unpaid registration is viewed or paid, expired
|
||||
early-bird prices are re-evaluated, and the user is warned if the total
|
||||
changed.
|
||||
- **Manual registration** (supervisor) — register someone on their behalf,
|
||||
creating an account if needed. Includes a "Record Payment" tab.
|
||||
- **Duplicate-account protection** — if an email and phone belong to two
|
||||
different accounts, the registration is blocked instead of merging them.
|
||||
- **Group tickets** — on events with "Named attendees" turned on, someone booking
|
||||
several places can name each person (with optional email/phone). Each named person
|
||||
gets their own ticket and their own attendee form; unnamed places share one ticket.
|
||||
Tickets go all to the payer, or each to the person (falling back to the payer).
|
||||
Attendees matching an existing account see their ticket in My Events. Names can be
|
||||
added or changed later from My Events, and attendees can be sent a link to fill in
|
||||
their own form. Available on the registration page, manual registration, At the
|
||||
door and the kiosk.
|
||||
- **Waitlist** — on events with a waitlist, sold-out tickets show "Join waitlist"
|
||||
(on the event page, the register page and the event list). When places free up — a
|
||||
cancellation, a smaller booking or a higher stock limit — the next person is
|
||||
emailed/WhatsApped a claim link and the places are held for them (24 hours by
|
||||
default) so nobody else can buy them. Unclaimed offers expire and move on; people
|
||||
who asked for more than came free can be offered what's free or skipped. Claiming
|
||||
works without logging in. People can leave from My Events.
|
||||
- **Parental consent (youth events)** — on events that require it, every attendee
|
||||
gives a date of birth; for anyone under 18, a parent or guardian gets an email and
|
||||
a WhatsApp with a consent link. They read the event's consent text, optionally give
|
||||
medical info, an emergency contact and photo consent, type their name to sign, and
|
||||
give or decline. Tickets are issued but can't be checked in until consent is given
|
||||
(supervisors can override, audited). Reminders go after 2 days, then every 3 days.
|
||||
Medical details are encrypted and deleted a set number of days after the event.
|
||||
- **Coupon codes** — percentage, fixed-amount or free (comp) codes, entered under
|
||||
"Have a code?" on the registration page, in My Events (for unpaid registrations),
|
||||
manual registration, At the door and the kiosk. Codes can be limited to events,
|
||||
ticket options or variants, dates, total and per-person uses, a minimum quantity,
|
||||
and specific people, roles or an email domain (restricted codes need a login).
|
||||
Early-bird prices either stack or the better price wins. The discount is locked in
|
||||
when applied and shows on invoices, receipts, emails and the Yoco checkout;
|
||||
cancelling frees the use. Managed from Supervisor → Coupon codes.
|
||||
- **Staff price** — a ticket option or variant can have a staff price that Staff and
|
||||
above get automatically, without a code.
|
||||
|
||||
---
|
||||
|
||||
## Payments, donations & refunds
|
||||
|
||||
- **Online card payments via Yoco** — checkout for the full outstanding balance,
|
||||
confirmed by a signed webhook. Keys and the webhook are set up from
|
||||
Site Settings → Yoco.
|
||||
- **Payment links** — supervisors can generate and send a Yoco link for any
|
||||
amount (including partial).
|
||||
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
|
||||
Each payment records **who recorded it** as well as who it's for.
|
||||
- **Refunds** — negative payments against an original payment, netted against
|
||||
the original method.
|
||||
- **Impersonate user** (Admin → Manage users) — an admin with 2FA can view the site as a
|
||||
user, to debug problems.
|
||||
- It needs a reason, lasts at most 30 minutes, and shows a bright "Viewing as" banner
|
||||
on every page.
|
||||
- It is strictly read-only. A server-side guard refuses every write made with the
|
||||
impersonation token before it reaches any route, and pages don't even refresh
|
||||
prices while viewing.
|
||||
- Admins can't be impersonated.
|
||||
- The start, end, reason and every page and API call are in the audit log, with each
|
||||
session's trail.
|
||||
- Optional email to the user (`notify_user_on_impersonation`).
|
||||
- **Registration funnel stats** — first-party and consent-based, with no third-party analytics.
|
||||
- A cookie notice asks once. Only visitors who allow it get a random visit id.
|
||||
- We record event page views and registration starts. Submits and payments come
|
||||
from the registration itself.
|
||||
- Each record holds only the stage, the traffic source (utm_source, or the short-link
|
||||
alias, which short links now pass as `?src=`) and the device type.
|
||||
- The Registration funnel report shows counts and conversion per stage over a date
|
||||
range, a daily trend, and breakdowns by source, device and ticket option.
|
||||
- A "Conversion (past month)" tile on the supervisor/admin dashboards covers live
|
||||
events.
|
||||
- Raw rows are rolled up into daily totals after 90 days.
|
||||
- **Post-event feedback** — a Feedback step in the event wizard.
|
||||
- Questions use the form builder, with new **star rating (1–5)** and **multiple
|
||||
choice** types (also available on attendee forms).
|
||||
- The form goes out at a chosen time to everyone who checked in, or to everyone
|
||||
registered, each with a personal link (`/feedback/<token>`). One reminder goes
|
||||
3 days later to anyone who hasn't answered.
|
||||
- Answers can be anonymous or named. "Send now" is available, and the questions
|
||||
lock once people have answered.
|
||||
- **Sponsor a spot** — per-event setting. A "Sponsor a spot" button on the event page
|
||||
lets anyone, including guests, pay for one or more spots (at the main ticket price)
|
||||
or any amount through Yoco. They can give anonymously or add a name and a message.
|
||||
The money is a donation tagged to the event and goes into its sponsorship pool, which
|
||||
supervisors assign to registrations with the usual donation flow.
|
||||
- An optional public progress bar ("12 of 20 spots sponsored") with an optional goal.
|
||||
- Sponsors get a thank-you receipt, and an Automations thank-you can go to all of an
|
||||
event's sponsors.
|
||||
- The Donations breakdown report shows each pool: received, assigned and remaining.
|
||||
- Who is helped is never shown.
|
||||
- **Donations** — users can donate from their dashboard (`/dashboard/user/donate`).
|
||||
Supervisors can **assign** a donation (fully or partly) to someone's
|
||||
registration and **unassign** it later. Assignments are kept as separate
|
||||
records, so the original donation never changes.
|
||||
- **Yoco reconciliation** — imported Yoco transactions can be matched to a
|
||||
registration, recorded as a donation (optionally choosing the donor), or
|
||||
ignored.
|
||||
- **Invoices & receipts** — branded PDFs: an invoice (itemised, early-bird
|
||||
discount, balance due, pay-now link and QR code) and a payment receipt. Sent
|
||||
automatically and re-sendable on demand by email or WhatsApp.
|
||||
- **Method normalisation** — Apple Pay / Google Pay / checkout payments count as
|
||||
"Card" in reports and user views.
|
||||
|
||||
---
|
||||
|
||||
## Tickets & check-in
|
||||
|
||||
- **Automatic tickets** — QR-coded tickets are generated when a registration is
|
||||
fully paid (or free), and sent as a PDF by email and/or WhatsApp.
|
||||
- **Quantity tickets** — one ticket can cover several people and be redeemed
|
||||
partially (e.g. check in 2 of 3).
|
||||
- **Named tickets** — group bookings can give each person their own ticket, shown
|
||||
with their name on the PDF, the scanner and the staff ticket list.
|
||||
- **QR scanner** — works in any mobile browser. A preview checks the ticket is
|
||||
for the selected event/section, then a confirmation screen shows holder, type
|
||||
and remaining quantity.
|
||||
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were
|
||||
checked in and how many remain.
|
||||
- **Scan stats** — recent scans and per-event scan counts.
|
||||
- **Offline scanning** — while online, a staff member locks an event section (or the
|
||||
whole event) to their device and downloads its tickets. They can then keep scanning
|
||||
with no connection; scans are queued on the device and sync automatically when it's
|
||||
back online (never redeeming more than is left). Only one device can hold a section,
|
||||
and while it's locked nobody else can scan it, online or offline. Problems found
|
||||
while syncing go into a report for a supervisor. Supervisors can force-release a
|
||||
lost device. The scanner can be added to the home screen (PWA) and opens offline.
|
||||
- **Printing** — A4 sheets (8 tickets per page) and A6 single tickets.
|
||||
- **Resend** — send tickets to a different email/phone at the door.
|
||||
|
||||
---
|
||||
|
||||
## Attendee forms
|
||||
|
||||
- **Form builder** per event — field types: Yes/No, Text, Paragraph, Number,
|
||||
Date, plus Statement and Heading blocks; required/optional, help text,
|
||||
reordering. The whole form can be marked required.
|
||||
- **Drafts** — partially filled forms are auto-saved.
|
||||
- **Responses** — view, filter (event, user, past/inactive) and print one
|
||||
response per A4 page. Supervisors can edit responses.
|
||||
|
||||
---
|
||||
|
||||
## Messaging & notifications
|
||||
|
||||
Every user picks a **notification preference**: Email, WhatsApp, or Both.
|
||||
|
||||
**Automatic notifications**
|
||||
|
||||
| Trigger | Goes to |
|
||||
|---------|---------|
|
||||
| Registration created / updated | Registrant (with invoice if unpaid) + event notify recipients |
|
||||
| Payment recorded / donation applied / unassigned | Registrant (with receipt) + admins |
|
||||
| Refund processed | Registrant + admins |
|
||||
| Tickets generated | Registrant (ticket PDF) |
|
||||
| Ticket checked in | Ticket holder |
|
||||
| Daily summary, 07:00, for each live event that hasn't started yet | Registrations inbox + event notify recipients |
|
||||
| Account registered, activation, login, password changed/reset, account closed | The account holder |
|
||||
|
||||
**Bulk messaging** (supervisor)
|
||||
|
||||
- **Email attendees** and **WhatsApp attendees** — filter by status or pick
|
||||
specific attendees, preview, send now or schedule for later.
|
||||
- **Placeholders** — `{{name}}`, `{{event.title}}`, `{{event.link}}`,
|
||||
`{{event.start}}`, `{{balance}}`, and `{{payment.link}}` (a live Yoco link for each recipient's
|
||||
outstanding balance).
|
||||
- **Broadcasts** — ad-hoc email/WhatsApp to any users or addresses, not tied to
|
||||
one event.
|
||||
- **Automations** — schedule a sequence of lifecycle emails for an event
|
||||
(pre-event, final reminder, thank-you, promo for another event).
|
||||
- **Manage scheduled** — view, edit or delete queued jobs per channel. Sends
|
||||
run in the background and are purged 24 h after sending.
|
||||
|
||||
**WhatsApp** is connected through the WAWP API: an admin links a number in
|
||||
Site Settings → WhatsApp (QR code or pairing code), with status, restart and
|
||||
logout controls. Disconnected sessions are recovered automatically.
|
||||
|
||||
---
|
||||
|
||||
## User dashboard (My Events)
|
||||
|
||||
- Registrations with status badges, event thumbnails, and Closed/Past/Inactive
|
||||
tags. Past and closed events are hidden by default.
|
||||
- Registration detail: itemised bill (early-bird lines grouped), payments,
|
||||
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
|
||||
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
|
||||
- **Ticket transfer** — offer a ticket to someone else before the event by typing
|
||||
their name and email/phone (no user search, and nothing reveals whether they have an
|
||||
account). They get a link to accept or decline; the ticket only moves once they
|
||||
accept, and the sender can cancel until then (offers expire after 7 days or at the
|
||||
event start). On accepting they get a new ticket and QR code, and an inactive account
|
||||
with an activation link if they're new. The old code stops working, and scanning it says it
|
||||
was transferred, both online and in offline-mode sync reports. One place can be split
|
||||
off a ticket covering several people. Billing stays with the payer. It's not allowed
|
||||
once a ticket has been scanned, and it can be turned off per event (supervisors can
|
||||
always transfer, from At the door → Tickets). On youth events the new holder needs
|
||||
their own parental consent, and the previous holder's form answers are removed.
|
||||
Every transfer is in the audit log.
|
||||
- **Payment history** (`/dashboard/user/payments`) — paginated, with filters by
|
||||
date, method and payment/refund.
|
||||
- **Donate** page.
|
||||
- **Forms** — complete outstanding attendee forms.
|
||||
- **Profile & Security** — edit details and notification preference, change
|
||||
password, see recent account activity (logins, password changes, device),
|
||||
sign out everywhere, deactivate or delete my data.
|
||||
|
||||
---
|
||||
|
||||
## Staff tools
|
||||
|
||||
- **Staff dashboard** — scan stats and quick actions.
|
||||
- **Ticket scanning** (`/dashboard/staff/ticket-scanning`) — camera QR scanner
|
||||
with event and section filters.
|
||||
- **Event tickets & printing** (`/dashboard/staff/event-tickets`) — list, search
|
||||
and print an event's tickets.
|
||||
|
||||
---
|
||||
|
||||
## Supervisor tools
|
||||
|
||||
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
|
||||
tickets sold, each vs. the previous month), revenue trend chart, top events,
|
||||
and recent scans. Tiles link to the matching report.
|
||||
- **Manage events** — the 6-step event wizard, ticket options, variants,
|
||||
sections, form and attachments.
|
||||
- **At the door** — search by name/phone/ticket ID, walk-in registration,
|
||||
payments, a **Check-In** tab for redeeming main tickets by quantity, and
|
||||
ticket printing/resending.
|
||||
- **Self-service kiosk** (`/self-service`) — a touchscreen page a supervisor
|
||||
unlocks for an event, where visitors register themselves. Existing accounts
|
||||
are matched by email/phone, and any change to their details needs
|
||||
confirmation.
|
||||
- **Manual registration** — register someone on their behalf and record their
|
||||
payment.
|
||||
- **Payments** — tabs for Payment, Refund, Donations (assign/unassign),
|
||||
Reconcile (Yoco transactions), and Payment Link.
|
||||
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
|
||||
discount codes, and see who used each one.
|
||||
- **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).
|
||||
- **Site banner** (`/set-banner`) — set the announcement bar.
|
||||
|
||||
---
|
||||
|
||||
## Admin tools
|
||||
|
||||
- **Admin dashboard** — same KPIs as supervisor, plus admin quick actions.
|
||||
- **Manage users** — server-side search and filters (role, active), paging,
|
||||
edit role and notification preference, revoke sessions, delete or
|
||||
**anonymise** personal data.
|
||||
- **Registrations** — all registrations, with filters (event, status, text),
|
||||
status counts, revenue and outstanding totals, and per-registration options,
|
||||
form answers and payments (with who recorded each one).
|
||||
- **Cashup** — see below.
|
||||
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
|
||||
refunds, donation assign/unassign, manual registrations, staff
|
||||
cancellations, event create/update/delete, settings changes, and coupon codes
|
||||
(created, edited, turned off, applied or removed by staff).
|
||||
- **Site settings** — see below.
|
||||
|
||||
---
|
||||
|
||||
## Cashup & event costs
|
||||
|
||||
- **Event costs** — once-off or per-item costs (optionally linked to a ticket
|
||||
option), with a "paid from takings" method so cash on hand is correct.
|
||||
- **Expected vs. actual** — expected takings per method (cash/card/EFT/other)
|
||||
compared with actual amounts, with variance shown.
|
||||
- **Cash denomination counts** — entered per staff member, at any time. The
|
||||
event's actual cash is the sum of these counts.
|
||||
- **Payment accountability by staff member** — what each person recorded, by
|
||||
method, with live cash variance.
|
||||
- **Close / quick close / reopen** an event — closing locks it from further
|
||||
edits and hides it from the kiosk and user actions. Drafts are saved as you
|
||||
go.
|
||||
- **Report tab** — a read-only cashup summary, shown automatically once the
|
||||
event is closed.
|
||||
- **Cashup audit trail** — every close/reopen with totals and who did it.
|
||||
|
||||
---
|
||||
|
||||
## Reports
|
||||
|
||||
A full-width workspace with shared filters (events, date range with presets,
|
||||
include past/inactive/closed) and a searchable grid of reports, each opening in
|
||||
a viewer with its own filters, stat tiles and bar charts.
|
||||
|
||||
| Category | Reports |
|
||||
|----------|---------|
|
||||
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
|
||||
| Registration | Registration funnel (views → started → submitted → paid, daily trend, by source / device / ticket option) · Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list · Feedback summary (response rate, average ratings, choice breakdowns, comments; compare events) |
|
||||
| Ticketing | Ticket usage summary |
|
||||
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
|
||||
| Cashup | Cashup reconciliation · Cashup audit trail |
|
||||
|
||||
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
|
||||
Email (to yourself), or WhatsApp (the PDF to your own number). Dashboard tiles
|
||||
deep-link into reports with `?report=&range=`.
|
||||
|
||||
---
|
||||
|
||||
## Site settings & setup wizard
|
||||
|
||||
**Setup wizard** (`/setup`) — on a fresh database, walks through organisation
|
||||
details, the first admin account and branding. No manual seeding needed.
|
||||
|
||||
**Site Settings** (`/dashboard/admin/settings`) — stored in the database and
|
||||
applied without a restart. Secrets are encrypted with AES-256-GCM.
|
||||
|
||||
| Tab | Contains |
|
||||
|-----|----------|
|
||||
| Organisation | Name, tagline, contact email/phone, address (with map link) |
|
||||
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
|
||||
| Notifications | Registrations inbox and admin notification emails |
|
||||
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
|
||||
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
|
||||
| WhatsApp | WAWP credentials, connect a number, status/restart/logout |
|
||||
| Yoco | Secret key (masked, test/live), webhook secret (generate by registering with Yoco, or enter), list/delete the account's webhooks |
|
||||
| Backups | List, run now, download database backups |
|
||||
|
||||
See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
|
||||
|
||||
---
|
||||
|
||||
## Accounts & security
|
||||
|
||||
- Register, log in, forgot/reset password, activate account (for accounts
|
||||
created on someone's behalf).
|
||||
- JWT authentication (HS256 pinned) with **session revocation** (token
|
||||
versioning) — users can sign out everywhere, and admins can do it for any user.
|
||||
- **Two-factor authentication** — a 6-digit login code by email or WhatsApp
|
||||
(10-minute expiry, 5 attempts, resend), with "Trust this device for 30 days".
|
||||
**Required for Admins and Supervisors**: their next login sends a code that turns
|
||||
it on, and they can change the channel but not turn it off (skipped only when no
|
||||
email or WhatsApp channel can deliver a code; `TWO_FACTOR_REQUIRED_ROLES=false`
|
||||
is the emergency switch). Optional for everyone else from Profile & Security.
|
||||
Admins can reset it for a locked-out user, and the kiosk asks for the code at
|
||||
setup.
|
||||
- **Login lockout** after repeated failed attempts, plus login rate limiting
|
||||
(`TRUST_PROXY` to read real IPs behind a proxy).
|
||||
- **Security event log** — logins, password changes and resets, with device
|
||||
and IP.
|
||||
- **Account closure** — deactivate, or anonymise personal data while keeping
|
||||
financial records.
|
||||
- **Upload hardening** — server-generated filenames, and uploads served with a
|
||||
sandbox CSP and `nosniff`.
|
||||
|
||||
---
|
||||
|
||||
## Help system
|
||||
|
||||
A floating **"Need help?"** button on every page opens a page-specific guide
|
||||
(tabs, quick links). Every page has dedicated content (`frontend/src/content/help/`),
|
||||
falling back to a general guide.
|
||||
|
||||
---
|
||||
|
||||
## Background jobs
|
||||
|
||||
| Job | Schedule |
|
||||
|-----|----------|
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
|
||||
| Daily event summary email | 07:00 daily |
|
||||
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
|
||||
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) |
|
||||
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) |
|
||||
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
|
||||
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
|
||||
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
|
||||
one above 500 MB of memory.
|
||||
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
|
||||
set.
|
||||
- Unhandled promise rejections are logged and don't crash the server.
|
||||
- Backend status page (`/`) and built-in API docs page (`/docs`).
|
||||
- Jest tests for early-bird tranche pricing and donation balances.
|
||||
- Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
## Page map
|
||||
|
||||
| Path | Who | What |
|
||||
|------|-----|------|
|
||||
| `/` | Public | Home |
|
||||
| `/events`, `/events/[id]` | Public | Event list and detail |
|
||||
| `/[alias]` | Public | Event short link |
|
||||
| `/register/[eventId]` | Public / User | Registration |
|
||||
| `/registration/success` | Public / User | Confirmation, form and pay |
|
||||
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Yoco return pages |
|
||||
| `/forms` | Public / User | Attendee forms |
|
||||
| `/contact`, `/legal/terms`, `/legal/privacy`, `/lockdown-rules` | Public | Info pages |
|
||||
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
|
||||
| `/setup` | First run | Setup wizard |
|
||||
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
|
||||
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
|
||||
@@ -59,8 +59,6 @@ Documentation: see Frontend README for environment and scripts.
|
||||
- DATABASE_URL
|
||||
- JWT_SECRET
|
||||
- PORT (optional; defaults to 3000 if not set)
|
||||
- YOCO_SECRET_KEY (for payments)
|
||||
- YOCO_WEBHOOK_SECRET (to verify webhooks)
|
||||
- EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, EMAIL_FROM (for ticket emails)
|
||||
- FRONTEND_URL or APP_BASE_URL (used in email links)
|
||||
|
||||
@@ -69,6 +67,7 @@ Documentation: see Frontend README for environment and scripts.
|
||||
Example for default backend: NEXT_PUBLIC_API_URL=http://localhost:5000
|
||||
|
||||
- Organisation name, logo, brand colour, and notification emails are configured via the admin panel (Admin → Site Settings) and do not need to be set in .env files.
|
||||
- Yoco payments are set up in Admin → Site Settings → Yoco: paste the secret key, then generate the webhook (or enter its secret). YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in backend/.env still work as fallbacks.
|
||||
|
||||
### 3) Run the apps
|
||||
|
||||
@@ -112,6 +111,7 @@ Documentation: see Frontend README for environment and scripts.
|
||||
- Backend README: ./backend/README.md
|
||||
- Frontend README: ./frontend/README.md
|
||||
- Site Settings — defaults reference: ./SETTINGS.md
|
||||
- Feature overview: ./FEATURES.md
|
||||
|
||||
## Recent Changes
|
||||
|
||||
|
||||
+18
@@ -57,6 +57,16 @@ or Branding tab has been saved even once — the wizard always writes
|
||||
| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
|
||||
| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
|
||||
|
||||
## Payments (Yoco)
|
||||
|
||||
Managed in the Yoco tab (through `/api/yoco`, not the generic settings endpoints).
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `yoco_secret_key` | `YOCO_SECRET_KEY` env var, else *(blank)* | Stored AES-256-GCM encrypted. If blank, online checkouts and sponsorships fail with "online payments are not configured". |
|
||||
| `yoco_webhook_secret` | `YOCO_WEBHOOK_SECRET` env var, else *(blank)* | Stored encrypted. If blank, Yoco webhooks are rejected, so payments aren't confirmed automatically. |
|
||||
| `yoco_webhook_id` | *(blank)* | The Yoco webhook whose secret is stored, set when it's generated from the tab. Used to mark it "Active" and to clear the secret when it's deleted. |
|
||||
|
||||
## Legal (Terms of Use / Privacy Policy)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
@@ -66,6 +76,8 @@ or Branding tab has been saved even once — the wizard always writes
|
||||
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
|
||||
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
|
||||
| `notify_user_on_impersonation` | `"false"` | When `"true"`, a user is emailed (with the reason) whenever an admin views their account with Impersonate. Set under Site Settings → Legal → Support access. |
|
||||
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
|
||||
## WhatsApp
|
||||
@@ -74,6 +86,12 @@ or Branding tab has been saved even once — the wizard always writes
|
||||
|---|---|---|
|
||||
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
|
||||
|
||||
## Backups
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `backup_retain_count` | `14` | How many nightly backups to keep on disk (oldest deleted beyond this count). Editable at Admin → Site Settings → Backups. See `BACKUP_ENABLED` (env var) to turn the nightly job off entirely. |
|
||||
|
||||
## Setup state
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|
||||
+31
-3
@@ -4,10 +4,22 @@ DATABASE_URL="postgresql://username:password@localhost:5432/hope_events"
|
||||
# ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
JWT_SECRET=your_jwt_secret_here_minimum_32_characters
|
||||
|
||||
# Two-factor authentication is required for admins and supervisors whenever
|
||||
# email (SMTP) or WhatsApp can deliver a code. Set to false only as an emergency
|
||||
# switch, e.g. if the mail server is down and admins are locked out.
|
||||
# TWO_FACTOR_REQUIRED_ROLES=true
|
||||
|
||||
# ─── Server ───────────────────────────────────────────────────────────────────
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
# Set this if the app runs behind a reverse proxy (e.g. nginx on a separate
|
||||
# server) so rate limiting reads the real client IP instead of the proxy's.
|
||||
# Accepts a hop count ("1"), "true"/"false", or comma-separated IP(s)/CIDR(s)
|
||||
# of your trusted proxy (e.g. "10.0.0.5" or "10.0.0.0/8"). Leave unset if the
|
||||
# app is not behind a proxy.
|
||||
# TRUST_PROXY=1
|
||||
|
||||
# ─── CORS ─────────────────────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed frontend origins
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
@@ -21,9 +33,11 @@ FRONTEND_URL=http://localhost:3000
|
||||
# EMAIL_PASS=your_email_password
|
||||
# EMAIL_FROM=no-reply@example.com
|
||||
|
||||
# ─── Payments (Yoco) ──────────────────────────────────────────────────────────
|
||||
YOCO_SECRET_KEY=your_yoco_secret_key
|
||||
YOCO_WEBHOOK_SECRET=your_yoco_webhook_secret
|
||||
# ─── Payments (Yoco) — optional fallbacks ─────────────────────────────────────
|
||||
# Preferred: set the secret key and generate the webhook in Admin → Site Settings →
|
||||
# Yoco (stored AES-256 encrypted). These are only used if the database has no value.
|
||||
# YOCO_SECRET_KEY=sk_test_...
|
||||
# YOCO_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# ─── Public URLs ──────────────────────────────────────────────────────────────
|
||||
# Used to build links in emails (password reset, activation, etc.)
|
||||
@@ -40,6 +54,19 @@ WAWP_INSTANCE_ID=your_wawp_instance_id
|
||||
DAILY_SUMMARY_ENABLED=true
|
||||
SCHEDULED_EMAILS_ENABLED=true
|
||||
SCHEDULED_EMAILS_INTERVAL_MS=30000
|
||||
# Daily 09:00 parental-consent job: reminders, and deleting medical details after events
|
||||
CONSENT_JOBS_ENABLED=true
|
||||
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
|
||||
WAITLIST_JOB_ENABLED=true
|
||||
# WAITLIST_JOB_INTERVAL_MS=600000
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
# ─── Error monitoring (Sentry) — optional ─────────────────────────────────────
|
||||
# Leave unset to disable entirely (a no-op, not an error). Set NEXT_PUBLIC_SENTRY_DSN
|
||||
# in frontend/.env too if you want frontend errors captured.
|
||||
# SENTRY_DSN=https://xxxxx@oxxxxxx.ingest.sentry.io/xxxxx
|
||||
|
||||
# ─── Note ─────────────────────────────────────────────────────────────────────
|
||||
# The following are managed via Admin → Site Settings and stored in the database:
|
||||
@@ -47,6 +74,7 @@ SCHEDULED_EMAILS_INTERVAL_MS=30000
|
||||
# - Registration notification emails
|
||||
# - SMTP settings (username and password stored AES-256-GCM encrypted, key
|
||||
# derived from JWT_SECRET — do not change JWT_SECRET after saving SMTP creds)
|
||||
# - Yoco secret key and webhook secret (encrypted the same way)
|
||||
# - Legal page content (operator name, Information Officer, website URL, etc.)
|
||||
# The first-time setup wizard handles initial configuration on fresh deployments.
|
||||
# You no longer need ORG_NAME, ORG_TAGLINE, EMAIL_HEADER_COLOR, REGISTRATIONS_EMAIL,
|
||||
|
||||
@@ -18,6 +18,16 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Event Options & Variants](#event-options--variants)
|
||||
- [Event Attachments](#event-attachments)
|
||||
- [Registrations](#registrations)
|
||||
- [Coupons](#coupons)
|
||||
- [Parental consent](#parental-consent)
|
||||
- [Waitlist](#waitlist)
|
||||
- [Offline scanning](#offline-scanning)
|
||||
- [Ticket transfer](#ticket-transfer)
|
||||
- [Rooms & groups](#rooms--groups)
|
||||
- [Sponsor a spot](#sponsor-a-spot)
|
||||
- [Post-event feedback](#post-event-feedback)
|
||||
- [Registration funnel](#registration-funnel)
|
||||
- [Impersonation](#impersonation)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -43,7 +53,9 @@ Tokens are issued by `POST /api/users/login`.
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/users` | public | Register new account |
|
||||
| POST | `/api/users/login` | public | Login, returns JWT |
|
||||
| POST | `/api/users/login` | public | Login, returns JWT — or a two-factor challenge when the account has 2FA on |
|
||||
| POST | `/api/users/login/2fa` | public | Exchange a two-factor code for the JWT |
|
||||
| POST | `/api/users/login/2fa/resend` | public | Send a new code for a pending login challenge (60s cooldown) |
|
||||
| POST | `/api/users/forgot` | public | Request password reset email |
|
||||
| POST | `/api/users/reset` | public | Reset password with token |
|
||||
| POST | `/api/users/activate` | public | Activate account from email link |
|
||||
@@ -51,9 +63,13 @@ Tokens are issued by `POST /api/users/login`.
|
||||
| PUT | `/api/users/profile` | user+ | Update own profile |
|
||||
| POST | `/api/users/revoke-sessions` | user+ | Revoke own sessions |
|
||||
| POST | `/api/users/close-account` | user+ | Deactivate own account |
|
||||
| POST | `/api/users/2fa/enable` | user+ | Start turning on 2FA — sends a code to the chosen channel |
|
||||
| POST | `/api/users/2fa/confirm` | user+ | Finish turning on 2FA with that code |
|
||||
| POST | `/api/users/2fa/disable` | user+ | Turn off 2FA (requires password) |
|
||||
| POST | `/api/users/verify-password` | user+ | Re-confirm own password without starting a new session |
|
||||
| GET | `/api/users` | supervisor+ | List users — supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` |
|
||||
| GET | `/api/users/:id` | admin | Get user by ID |
|
||||
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber) |
|
||||
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber). `twoFactorEnabled: false` resets a user's 2FA; it cannot be switched on this way |
|
||||
| DELETE | `/api/users/:id` | admin | Deactivate user (`isActive: false`) |
|
||||
| POST | `/api/users/:id/revoke-sessions` | admin | Revoke all sessions for a user |
|
||||
| POST | `/api/users/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account |
|
||||
@@ -71,10 +87,32 @@ Returns: `{ _id, name, email, role, token }`
|
||||
|
||||
### POST /api/users/login
|
||||
```json
|
||||
{ "email": "jane@example.com", "password": "securepass123" }
|
||||
{ "email": "jane@example.com", "password": "securepass123", "deviceToken": "optional-trusted-device-token" }
|
||||
```
|
||||
Returns: `{ _id, name, email, role, token }`
|
||||
|
||||
If the account has two-factor authentication on and `deviceToken` is missing, expired or not recognised, no token is issued. A 6-digit code is sent by email or WhatsApp and the response is instead:
|
||||
```json
|
||||
{ "twoFactorRequired": true, "challengeId": "uuid", "method": "email", "destination": "j•••@example.com" }
|
||||
```
|
||||
|
||||
**Required for admins and supervisors.** An admin or supervisor without 2FA gets the same challenge with `"enrolling": true` (trusted devices don't skip it), sent to their email when SMTP is configured, otherwise WhatsApp. Verifying it turns 2FA on with that channel. If neither channel can deliver a code, the requirement is skipped so the account isn't locked out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off.
|
||||
|
||||
### POST /api/users/login/2fa
|
||||
```json
|
||||
{ "challengeId": "uuid", "code": "123456", "rememberDevice": true }
|
||||
```
|
||||
Returns: `{ _id, name, email, role, token }`, plus `deviceToken` when `rememberDevice` is `true`. Send that `deviceToken` with later logins to skip the code for 30 days. Codes expire after 10 minutes and allow 5 attempts. Trusted devices are cleared when the password changes or sessions are revoked.
|
||||
|
||||
### Two-factor setup (`/api/users/2fa/*`)
|
||||
- `POST /api/users/2fa/enable` — `{ "method": "email" | "whatsapp", "password": "..." }` sends a code to that channel and returns `{ challengeId, method, destination }`. WhatsApp needs a valid phone number on the account and a connected WhatsApp session.
|
||||
- `POST /api/users/2fa/confirm` — `{ "challengeId": "uuid", "code": "123456" }` turns 2FA on.
|
||||
- `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off. Returns 403 for admins and supervisors, who must keep it on (they can switch channel with `enable`/`confirm`).
|
||||
|
||||
An admin or supervisor session without 2FA (from before the requirement, or after a promotion or 2FA reset) is rejected by every protected route with `401 { "code": "TWO_FACTOR_REQUIRED" }`; logging in again enrols them. Promoting a user to supervisor/admin ends their sessions when they don't have 2FA yet.
|
||||
|
||||
At login, if a WhatsApp code can't be sent, the code falls back to the account's email address.
|
||||
|
||||
### PUT /api/users/profile
|
||||
```json
|
||||
{ "name": "Jane Doe", "phoneNumber": "+27829999999", "notificationPreference": "email" }
|
||||
@@ -199,7 +237,11 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
|
||||
| GET | `/api/registrations` | staff+ | All registrations |
|
||||
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
|
||||
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
|
||||
| POST | `/api/registrations/manual` | supervisor+ | Manual registration |
|
||||
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
|
||||
| PUT | `/api/registrations/:id/attendees` | user+ | Replace the named attendees (`{ attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }`); owner or staff |
|
||||
| POST | `/api/registrations/:id/attendees/:attendeeId/send-form-link` | user+ | Email/WhatsApp an attendee the link to their own form |
|
||||
| POST | `/api/registrations/:id/coupon` | user+ | Apply a code to an existing registration (owner, or staff on someone's behalf) |
|
||||
| DELETE | `/api/registrations/:id/coupon` | staff+ | Remove the code (refused once a ticket on a paid registration has been scanned) |
|
||||
|
||||
### POST /api/registrations
|
||||
```json
|
||||
@@ -211,6 +253,16 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
|
||||
]
|
||||
}
|
||||
```
|
||||
Add `"couponCode": "STAFF50"` to apply a discount code. The code is checked before anything is saved (400 with the reason if it can't be used). If the last use is taken by someone else in the moment between checking and saving, the registration is still created at full price and the response includes `couponError`.
|
||||
|
||||
**Group tickets:** on events with `allowNamedAttendees`, `POST /api/registrations` and `/manual` accept `attendees: [{ eventOptionId, variantId?, name, email?, phone? }]` (main-ticket places only, no more than the places bought) and `ticketDelivery: "payer" | "each"`.
|
||||
- Each named attendee gets their own quantity-1 ticket; unnamed places share one quantity ticket.
|
||||
- An attendee whose email/phone matches one existing account is linked to it, so the ticket also appears in that person's `GET /api/tickets/mytickets`.
|
||||
- With `"each"`, tickets are emailed/WhatsApped to attendees directly, falling back to the payer.
|
||||
- Form responses can carry `attendeeId`.
|
||||
|
||||
Registration responses include `discountTotal` (the coupon discount), `subtotalComputed` (items before the discount), `totalDueComputed` (after it) and `couponRedemptions` (the active code).
|
||||
|
||||
For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
```json
|
||||
{
|
||||
@@ -233,6 +285,210 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
|
||||
---
|
||||
|
||||
## Impersonation
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/admin/impersonate/:userId` | admin (2FA on) | `{ reason }` → `{ token, session }`. The token acts as the user, is read-only, and lasts 30 minutes. Refused for admins and inactive users |
|
||||
| POST | `/api/admin/impersonate/end` | impersonation token | End the session |
|
||||
| POST | `/api/admin/impersonate/log` | impersonation token | `{ path }` — page viewed (audit trail) |
|
||||
| GET | `/api/admin/impersonate/current` | impersonation token | The live session |
|
||||
| GET | `/api/admin/impersonation-sessions` | admin | Sessions with their request trail |
|
||||
|
||||
Read-only enforcement:
|
||||
- A guard mounted before every route returns `403 { code: "IMPERSONATION_READ_ONLY" }` for any non-GET/HEAD/OPTIONS request made with an impersonation token. The only exceptions are `end` and `log`. Blocked attempts are logged.
|
||||
- An ended or expired session gets `401 { code: "IMPERSONATION_ENDED" }`.
|
||||
- GET handlers skip side-effect writes (pricing refresh) while `req.readOnly` is set.
|
||||
|
||||
## Registration funnel
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/analytics/track` | public (rate-limited) | `{ sessionId, eventId, stage: 'view'\|'start', source? }` → always `204`. Bots are ignored; device type comes from the User-Agent |
|
||||
| GET | `/api/analytics/funnel?eventIds=&from=&to=` | supervisor+ | `{ totals, conversion, trend, bySource, byDevice, byOption, perEvent }` |
|
||||
|
||||
How the stages are recorded:
|
||||
- `POST /api/registrations` accepts `analyticsSessionId`. It's stored on the registration and records the `submit` stage.
|
||||
- `paid` is read from that registration's status.
|
||||
- Counts are sessions that reached a stage or a later one.
|
||||
|
||||
Data handling:
|
||||
- Sessions inactive for 90 days are rolled up into `AnalyticsDaily` by a daily job (`ANALYTICS_ROLLUP_ENABLED`).
|
||||
- `/api/stats/overview` now includes `funnel: { views, paid, conversion }` for live events over the past 30 days.
|
||||
|
||||
## Post-event feedback
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/feedback/event/:eventId` | supervisor+ | The event's feedback form (with `invited` / `responses` counts), or null |
|
||||
| PUT | `/api/feedback/event/:eventId` | supervisor+ | `{ sendAt, audience: 'checked_in'\|'all', anonymous, intro, fields[] }` — fields can't change once answered (409) |
|
||||
| DELETE | `/api/feedback/event/:eventId` | supervisor+ | Remove the form |
|
||||
| POST | `/api/feedback/event/:eventId/send-now` | supervisor+ | Send now (also invites anyone not yet invited) |
|
||||
| GET / POST | `/api/feedback/public/:token` | link holder | Load the form / submit `{ answers: { fieldId: value } }` once |
|
||||
| GET | `/api/feedback/summary?eventIds=a,b` | supervisor+ | Per event: invited, responses, response rate, and per question: rating average + distribution, choice counts, text answers (names only when not anonymous) |
|
||||
|
||||
Notes:
|
||||
- New field types `rating` (answers `"1"`–`"5"`) and `multiple_choice` (`options`: array of strings). They can be used on attendee forms too.
|
||||
- Only HMACs of link tokens are stored, and a reminder issues a new link.
|
||||
- A job every 15 minutes (`FEEDBACK_JOB_ENABLED`, `FEEDBACK_JOB_INTERVAL_MS`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
|
||||
|
||||
## Sponsor a spot
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/sponsorships/event/:eventId` | public | `{ enabled, open, spotPrice, showProgress, goalSpots, spotsFunded, sponsors }` — never recipients |
|
||||
| POST | `/api/sponsorships/checkout` | public (optional login) | `{ eventId, spots? \| amount?, anonymous?, sponsorName?, message?, name?, email? }` → Yoco `redirectUrl`. Guests give a name and email |
|
||||
| GET | `/api/sponsorships/event/:eventId/pool` | supervisor+ | `{ received, assigned, remaining, sponsors, spotsFunded, spotPrice, goalSpots }` |
|
||||
|
||||
How the money is handled:
|
||||
- A spot costs the cheapest paid main ticket.
|
||||
- Yoco checkout metadata carries `type: 'sponsorship'`. The webhook records the payment as `isDonation` + `isSponsorship` against the event, together with `sponsorName`, `sponsorMessage` and `sponsorAnonymous`.
|
||||
- Pool funds are assigned with `PUT /api/payments/assign-donation`.
|
||||
|
||||
Other changes:
|
||||
- Event fields: `allowSponsorships`, `sponsorshipGoalSpots`, `showSponsorshipProgress`.
|
||||
- `POST /api/events/:id/email-attendees` accepts `filter.audience: 'sponsors'`.
|
||||
- `POST /api/automations/schedule` jobs accept `audience: 'sponsors'`.
|
||||
|
||||
## Rooms & groups
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/allocations/event/:eventId` | supervisor+ | Board: sets (groups, assignments), named attendees, possible leaders, unnamed places |
|
||||
| POST | `/api/allocations/sets` | supervisor+ | `{ eventId, name, showOnTicket? }` |
|
||||
| PUT / DELETE | `/api/allocations/sets/:id` | supervisor+ | Rename / show on tickets; delete |
|
||||
| POST | `/api/allocations/sets/:id/groups` | supervisor+ | `{ name, capacity?, gender? ('male'\|'female'), leaderUserId? }` |
|
||||
| PUT / DELETE | `/api/allocations/groups/:id` | supervisor+ | Edit (can't drop below current members or break gender); delete (members become unassigned) |
|
||||
| PUT | `/api/allocations/sets/:id/assignments` | supervisor+ | `{ attendeeId, groupId }` (null unassigns); enforces capacity and gender |
|
||||
| DELETE | `/api/allocations/sets/:id/assignments` | supervisor+ | Clear the set |
|
||||
| POST | `/api/allocations/sets/:id/auto` | supervisor+ | `{ mode: 'together' \| 'split' }` — places everyone not yet placed |
|
||||
| GET | `/api/allocations/sets/:id/lists?medical=true` | supervisor+ | Groups with members for printing (medical view is audited) |
|
||||
| GET | `/api/allocations/my-groups` | staff+ | Groups the caller leads (medical details for supervisors only) |
|
||||
|
||||
Related changes:
|
||||
- `Event.collectGender` makes a gender (`male` / `female`) required for each named attendee.
|
||||
- Email and WhatsApp attendees accept `filter.allocationGroupId`.
|
||||
|
||||
## Ticket transfer
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }` → `201`. Creates an offer and sends a link to the details given. **Nothing changes on the ticket yet.** A new offer replaces an open one |
|
||||
| DELETE | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | Withdraw the open offer |
|
||||
| GET | `/api/tickets/transfer-offer/:token` | link holder | `{ status, toName, fromName (first name), event, option, expiresAt }` |
|
||||
| POST | `/api/tickets/transfer-offer/:token/accept` | link holder | Accept — the transfer happens now (all checks run again) |
|
||||
| POST | `/api/tickets/transfer-offer/:token/decline` | link holder | Decline |
|
||||
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
|
||||
|
||||
Privacy:
|
||||
- There is no user search. The sender's request is checked (rules, required fields) without looking up any account, and no response reveals whether the email/phone belongs to an account.
|
||||
- Offers expire after 7 days or when the event starts.
|
||||
- Only an HMAC of the link token is stored.
|
||||
|
||||
Rules:
|
||||
- Only before the event starts, and only for tickets that have never been scanned.
|
||||
- `Event.allowTicketTransfer` turns it off for non-supervisors.
|
||||
|
||||
What a transfer does:
|
||||
- The new holder becomes the ticket's attendee and gets a new QR code. A shared ticket for several places has one place split off into its own ticket.
|
||||
- An account is matched by email/phone, or created inactive with an activation link.
|
||||
- The previous holder's attendee form answers are deleted, and the medical details on their consent are cleared. A minor on a youth event gets a new consent request.
|
||||
|
||||
Old QR codes:
|
||||
- `scan-preview` and `scan` return `410 { code: "TICKET_TRANSFERRED" }`.
|
||||
- Offline sync reports them as `transferred`.
|
||||
- Each transfer is audited as `ticket_transferred`.
|
||||
|
||||
## Offline scanning
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/scan-locks?eventId=` | staff+ | Active offline locks for an event |
|
||||
| POST | `/api/scan-locks` | staff+ | Lock `{ eventId, sectionId?, deviceId, deviceName? }` to this device (409 if the scope is held elsewhere) |
|
||||
| GET | `/api/scan-locks/:id/snapshot?deviceId=` | staff+ | Tickets valid in the lock's scope (lock holder only) |
|
||||
| POST | `/api/scan-locks/:id/sync` | staff+ | `{ deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }` — idempotent; returns accepted, duplicates, conflicts |
|
||||
| DELETE | `/api/scan-locks/:id` | staff+ | Release (`{ deviceId }`, lock holder only) |
|
||||
| POST | `/api/scan-locks/:id/force-release` | supervisor+ | Release a lost device's lock |
|
||||
| GET | `/api/scan-locks/reports?eventId=&unreviewed=` | supervisor+ | Sync reports |
|
||||
| POST | `/api/scan-locks/reports/:id/review` | supervisor+ | Mark a report reviewed |
|
||||
|
||||
How locks work:
|
||||
- One device per scope. A whole-event lock (`sectionId` null) clashes with every section lock on that event.
|
||||
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and `deviceId`. It enforces the section on the server, and returns `423 { code: "SCAN_LOCKED" }` when another device holds a lock covering the ticket.
|
||||
|
||||
How syncing works:
|
||||
- Synced scans never redeem more than is left on a ticket.
|
||||
- Conflicts are recorded in the sync report: `not_found`, `cancelled`, `wrong_scope`, `already_used`, `partly_used` and `consent`.
|
||||
- Check-in messages are sent after the sync.
|
||||
- Locks, releases, force-releases and syncs are written to the admin audit log.
|
||||
|
||||
## Waitlist
|
||||
|
||||
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity |
|
||||
| GET | `/api/waitlist/mine` | user+ | My active entries |
|
||||
| DELETE | `/api/waitlist/:id` | user+ | Leave (an open offer moves on) |
|
||||
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers |
|
||||
| GET | `/api/waitlist/events/:eventId` | staff+ | The queue for an event |
|
||||
| POST | `/api/waitlist/:id/offer` | supervisor+ | Offer now (`{ quantity? }`), even over the stock limit |
|
||||
| POST | `/api/waitlist/:id/move` | supervisor+ | `{ direction: "up" \| "down" }` |
|
||||
| POST | `/api/waitlist/:id/remove` | supervisor+ | Take someone off |
|
||||
|
||||
How offers work:
|
||||
- Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks.
|
||||
- Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit.
|
||||
- A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers.
|
||||
- Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer.
|
||||
- Manual registration and adding places to an existing booking are now checked against stock too.
|
||||
|
||||
## Parental consent
|
||||
|
||||
Youth events (`requiresParentalConsent`, plus `consentText`, `consentCollectMedical`, `consentCollectEmergency` and `consentCollectPhoto` on the event) need every main place named with a `dateOfBirth`. Attendees under 18 on the start date also need `parentName`, `parentEmail` and `parentPhone`. Changing `consentText` bumps `consentTextVersion`.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/consent/:token` | public | What the parent sees: attendee, event, consent text, fields to fill in |
|
||||
| POST | `/api/consent/:token` | public | `{ decision: "give"\|"decline", signedName, isGuardian: true, medical?, emergency?, photoConsent? }` |
|
||||
| GET | `/api/consent/events/:eventId` | staff+ | Consent status per attendee, with counts |
|
||||
| POST | `/api/consent/requests/:id/resend` | user+ | Re-send one request with a new link (registrant or staff) |
|
||||
| POST | `/api/consent/events/:eventId/resend-pending` | supervisor+ | Re-send every pending request |
|
||||
| POST | `/api/consent/events/:eventId/rerequest` | supervisor+ | New requests for consent given on older text, and minors with none |
|
||||
| GET | `/api/consent/events/:eventId/medical` | supervisor+ | Decrypted medical & emergency list (audited as `medical_list_viewed`) |
|
||||
|
||||
How requests work:
|
||||
- Requests go to the parent by both email and WhatsApp, whatever anyone's notification preference.
|
||||
- Links are random tokens; only an HMAC is stored. They expire when the event ends.
|
||||
- The answer stores the signed name, time, IP, device, and the exact text and version shown.
|
||||
- Medical and emergency details are AES-256-GCM encrypted. A daily job (09:00, `CONSENT_JOBS_ENABLED=false` to disable) sends reminders (day 2, then every 3 days) and deletes them `consent_medical_retention_days` (default 30) after the event.
|
||||
|
||||
Check-in rules:
|
||||
- `GET /api/tickets/scan-preview/:qrCode` returns `consentStatus` (`not_required`, `missing`, `pending`, `given` or `declined`).
|
||||
- `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given.
|
||||
- Supervisors can pass `overrideConsent: true`, which is audited as `consent_override`.
|
||||
|
||||
## Coupons
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/coupons/validate` | optional | Check a code against a basket (`{ code, eventId, options, forUserId? }`) |
|
||||
| GET | `/api/coupons` | supervisor+ | List codes with use counts and total discount |
|
||||
| POST | `/api/coupons` | supervisor+ | Create a code |
|
||||
| GET | `/api/coupons/:id` | supervisor+ | One code with its uses |
|
||||
| PUT | `/api/coupons/:id` | supervisor+ | Edit or turn off a code |
|
||||
| GET | `/api/coupons/redemptions` | supervisor+ | Uses for the Discounts report (`eventIds`, `from`, `to`) |
|
||||
|
||||
- **Types:** `percent` (value 1–100), `fixed` (rand amount), `comp` (free).
|
||||
- **Scope:** `eventIds` (none = every event) and `options: [{ eventOptionId, variantId? }]` (none = the whole registration).
|
||||
- **Restrictions:** `userIds`, `restrictRoles` and `restrictDomain`. A match on any one is enough, and restricted codes need a known account.
|
||||
- **Limits:** `validFrom`, `validUntil`, `maxUses`, `maxUsesPerUser` (default 1) and `minQuantity`.
|
||||
- **Early-bird:** `stackEarlyBird: false` means the better price wins; `true` discounts the early-bird price.
|
||||
- **Locked amount:** the discount is locked when applied. Editing items recalculates percentage and comp codes, but fixed codes keep their amount. Cancelling releases the use.
|
||||
|
||||
Options and variants also accept an optional `staffPrice`, charged automatically to Staff and above when it's lower than the normal price.
|
||||
|
||||
## Payments
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
@@ -450,7 +706,7 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
|
||||
|
||||
## Settings
|
||||
|
||||
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
|
||||
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, Yoco secrets) are stored AES-256-GCM encrypted. Yoco secrets are never returned by `/api/settings/all` and can't be written through `PUT /api/settings`; use `/api/yoco` below.
|
||||
|
||||
For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root.
|
||||
|
||||
@@ -462,6 +718,18 @@ For what each individual setting key defaults to when it's never been saved, see
|
||||
| PUT | `/api/settings` | admin | Upsert settings; `smtp_user`/`smtp_pass` encrypted before storage; sending `••••••••` for `smtp_pass` keeps existing value |
|
||||
| POST | `/api/settings/test-smtp` | admin | Test SMTP with provided credentials; sends real test email to admin on success. The admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
|
||||
|
||||
## Yoco
|
||||
|
||||
Managed from Admin → Site Settings → Yoco. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/yoco/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
|
||||
| PUT | `/api/yoco/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
|
||||
| GET | `/api/yoco/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
|
||||
| POST | `/api/yoco/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
|
||||
| DELETE | `/api/yoco/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
|
||||
|
||||
### POST /api/settings/test-smtp
|
||||
```json
|
||||
{
|
||||
|
||||
+18
-4
@@ -79,11 +79,12 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
|
||||
| `DATABASE_URL` | Yes | PostgreSQL connection string |
|
||||
| `JWT_SECRET` | Yes | Secret key for JWTs (32+ random characters). Also used to derive the AES-256 encryption key for sensitive settings (SMTP credentials) stored in the DB — must be set before saving SMTP settings. |
|
||||
| `FRONTEND_URL` | Yes | Comma-separated allowed CORS origins |
|
||||
| `YOCO_SECRET_KEY` | Yes | Yoco secret API key |
|
||||
| `YOCO_WEBHOOK_SECRET` | Yes | Yoco webhook HMAC secret |
|
||||
| `YOCO_SECRET_KEY` | — | Fallback Yoco secret API key (preferred: set via Admin → Site Settings → Yoco) |
|
||||
| `YOCO_WEBHOOK_SECRET` | — | Fallback Yoco webhook secret (preferred: generate or enter it via Admin → Site Settings → Yoco) |
|
||||
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
|
||||
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
|
||||
| `PORT` | — | Port to listen on (default `3000`) |
|
||||
| `TRUST_PROXY` | — | Set when running behind a reverse proxy (e.g. nginx on a separate server), so `req.ip`/`X-Forwarded-For` are read correctly by rate limiting. Accepts a hop count (`1`), `true`/`false`, or comma-separated trusted proxy IP(s)/CIDR(s). |
|
||||
| `NODE_ENV` | — | `production` or `development` |
|
||||
| `WAWP_ACCESS_TOKEN` | — | WAWP fallback token (preferred: set via Admin → Site Settings) |
|
||||
| `WAWP_INSTANCE_ID` | — | WAWP fallback instance ID (preferred: set via Admin → Site Settings) |
|
||||
@@ -98,7 +99,9 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
|
||||
|
||||
> **Organisation name, branding colour, logo, registration notification email, SMTP settings, and legal page content** are now managed through the admin panel at **Admin → Site Settings** and stored in the database. You no longer need `ORG_NAME`, `ORG_TAGLINE`, `EMAIL_HEADER_COLOR`, `REGISTRATIONS_EMAIL`, or SMTP vars in `.env` (they still work as fallbacks). On a fresh deployment these are set via the first-time setup wizard.
|
||||
>
|
||||
> **SMTP credentials** (`smtp_user`, `smtp_pass`) are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving SMTP credentials — the stored values will become unreadable.
|
||||
> **SMTP credentials** (`smtp_user`, `smtp_pass`) and the **Yoco secret key and webhook secret** are stored **AES-256-GCM encrypted** in the database. The encryption key is derived from `JWT_SECRET`. Do not change `JWT_SECRET` after saving them — the stored values will become unreadable.
|
||||
>
|
||||
> To move existing Yoco values from `.env` into the database, run `npm run migrate:env-to-db` (or save them in the Yoco tab).
|
||||
|
||||
---
|
||||
|
||||
@@ -332,6 +335,15 @@ Full interactive API docs available at `GET /docs` (requires admin JWT — pass
|
||||
| PUT | `/` | admin | Upsert settings; `smtp_user` and `smtp_pass` are AES-256-GCM encrypted before storage; sending `••••••••` for `smtp_pass` is a no-op (keeps existing value) |
|
||||
| POST | `/test-smtp` | admin | Test SMTP connection with provided credentials; success returns `{ message }`, failure returns `{ message, raw }` where `message` is human-readable and `raw` is the original SMTP error. The short-lived admin JWT returned by `POST /api/setup/register` also qualifies during the setup wizard. |
|
||||
|
||||
#### Yoco — `/api/yoco`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/config` | admin | Yoco settings with secrets masked card-style (`sk_test_***…aba`), the source of each (`db`/`env`/`none`), test/live mode and the default webhook URL |
|
||||
| PUT | `/config` | admin | Save `secretKey` and/or `webhookSecret` (AES-256-GCM encrypted). A new key is checked with Yoco first; `''` clears a value |
|
||||
| GET | `/webhooks` | admin | Webhooks on the Yoco account, flagged `isActive` (its secret is the stored one) and `pointsHere` |
|
||||
| POST | `/webhooks` | admin | Register a webhook at Yoco (`{ name?, url? }`, URL defaults to `BACKEND_URL/api/webhooks/yoco`, must be public https) and store the secret it returns |
|
||||
| DELETE | `/webhooks/:id` | admin | Delete a webhook at Yoco; clears the stored secret if it was the active one |
|
||||
|
||||
#### Setup — `/api/setup`
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
@@ -499,6 +511,8 @@ The resolution priority at registration/payment time is: **variant tier → vari
|
||||
|
||||
## Payments (Yoco)
|
||||
|
||||
Setup: Admin → Site Settings → **Yoco**. Paste the secret key (checked with Yoco before saving), then **Generate** a webhook — this registers `BACKEND_URL/api/webhooks/yoco` with Yoco and stores the secret Yoco returns (it's only shown once). Or **Enter** the secret of a webhook you registered yourself. The tab also lists the account's webhooks and can delete them.
|
||||
|
||||
1. Frontend calls `POST /api/payments/yoco-checkout`
|
||||
2. Backend calls `refreshPricingForRegistration` — if any early-bird price changed, returns `{ priceUpdated: true, newTotal }` (HTTP 200) and does **not** create a checkout. Frontend shows warning; user retries.
|
||||
3. On the retry (or first attempt when prices are current), backend creates a Yoco checkout and returns `{ redirectUrl }`.
|
||||
@@ -523,6 +537,6 @@ pm2 save && pm2 startup
|
||||
- [ ] `JWT_SECRET` is long (32+ chars) and secret
|
||||
- [ ] `FRONTEND_URL` lists only production origins
|
||||
- [ ] `NODE_ENV=production`
|
||||
- [ ] Yoco webhook registered and HTTPS
|
||||
- [ ] Yoco secret key saved and webhook generated (Site Settings → Yoco), over HTTPS
|
||||
- [ ] WAWP credentials saved via admin panel
|
||||
- [ ] SMTP credentials correct
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/**/*.test.js'],
|
||||
};
|
||||
Generated
+5363
-172
File diff suppressed because it is too large
Load Diff
+20
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.9.3",
|
||||
"version": "1.25.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "jest",
|
||||
"postinstall": "prisma generate",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:deploy": "prisma migrate deploy && prisma generate",
|
||||
@@ -17,23 +17,37 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
"@sentry/node": "^10.71.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"ics": "^3.12.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.2.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.5",
|
||||
"nodemailer": "^10.0.16",
|
||||
"pdfkit": "^0.17.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"raw-body": "^3.0.0",
|
||||
"uuid": "^9.0.1"
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.4.2",
|
||||
"nodemon": "^3.0.1",
|
||||
"prisma": "^5.4.2"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@5.22.0": true,
|
||||
"@prisma/client@5.22.0": true,
|
||||
"@prisma/engines@5.22.0": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"@parcel/watcher": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AdminAuditAction" AS ENUM ('refund_created', 'donation_assigned', 'donation_unassigned', 'registration_created_manual', 'registration_cancelled', 'event_created', 'event_updated', 'event_deleted', 'settings_updated');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AdminAuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"actorRole" TEXT NOT NULL,
|
||||
"action" "AdminAuditAction" NOT NULL,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"targetId" TEXT,
|
||||
"metadata" JSONB,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AdminAuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminAuditLog_actorId_createdAt_idx" ON "AdminAuditLog"("actorId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminAuditLog_action_createdAt_idx" ON "AdminAuditLog"("action", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminAuditLog_targetType_targetId_idx" ON "AdminAuditLog"("targetType", "targetId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AdminAuditLog" ADD CONSTRAINT "AdminAuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TwoFactorMethod" AS ENUM ('email', 'whatsapp');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_enabled';
|
||||
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_disabled';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "twoFactorMethod" "TwoFactorMethod";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TwoFactorChallenge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"purpose" TEXT NOT NULL,
|
||||
"method" "TwoFactorMethod" NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TwoFactorChallenge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TrustedDevice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"device" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TrustedDevice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TwoFactorChallenge_userId_idx" ON "TwoFactorChallenge"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TrustedDevice_tokenHash_key" ON "TrustedDevice"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TrustedDevice_userId_idx" ON "TrustedDevice"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TwoFactorChallenge" ADD CONSTRAINT "TwoFactorChallenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrustedDevice" ADD CONSTRAINT "TrustedDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CouponType" AS ENUM ('percent', 'fixed', 'comp');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_created';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_disabled';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_applied_manual';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventOption" ADD COLUMN "staffPrice" DOUBLE PRECISION;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "OptionVariant" ADD COLUMN "staffPrice" DOUBLE PRECISION;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "discountTotal" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Coupon" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"type" "CouponType" NOT NULL,
|
||||
"value" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"stackEarlyBird" BOOLEAN NOT NULL DEFAULT false,
|
||||
"validFrom" TIMESTAMP(3),
|
||||
"validUntil" TIMESTAMP(3),
|
||||
"maxUses" INTEGER,
|
||||
"maxUsesPerUser" INTEGER DEFAULT 1,
|
||||
"minQuantity" INTEGER,
|
||||
"restrictRoles" "UserRole"[],
|
||||
"restrictDomain" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Coupon_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CouponEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"couponId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "CouponEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CouponOption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"couponId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
|
||||
CONSTRAINT "CouponOption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CouponUser" (
|
||||
"id" TEXT NOT NULL,
|
||||
"couponId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "CouponUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CouponRedemption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"couponId" TEXT NOT NULL,
|
||||
"registrationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"discountAmount" DOUBLE PRECISION NOT NULL,
|
||||
"appliedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"releasedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "CouponRedemption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Coupon_code_key" ON "Coupon"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Coupon_createdById_idx" ON "Coupon"("createdById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponEvent_eventId_idx" ON "CouponEvent"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CouponEvent_couponId_eventId_key" ON "CouponEvent"("couponId", "eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponOption_couponId_idx" ON "CouponOption"("couponId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponOption_eventOptionId_idx" ON "CouponOption"("eventOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponOption_variantId_idx" ON "CouponOption"("variantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponUser_userId_idx" ON "CouponUser"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CouponUser_couponId_userId_key" ON "CouponUser"("couponId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponRedemption_couponId_releasedAt_idx" ON "CouponRedemption"("couponId", "releasedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponRedemption_registrationId_idx" ON "CouponRedemption"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponRedemption_userId_idx" ON "CouponRedemption"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponRedemption_appliedById_idx" ON "CouponRedemption"("appliedById");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Coupon" ADD CONSTRAINT "Coupon_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_appliedById_fkey" FOREIGN KEY ("appliedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TicketDelivery" AS ENUM ('payer', 'each');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "allowNamedAttendees" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "FormResponse" ADD COLUMN "attendeeId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "ticketDelivery" "TicketDelivery" NOT NULL DEFAULT 'payer';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Ticket" ADD COLUMN "attendeeId" TEXT,
|
||||
ADD COLUMN "holderUserId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Attendee" (
|
||||
"id" TEXT NOT NULL,
|
||||
"registrationId" TEXT NOT NULL,
|
||||
"registrationOptionId" TEXT,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"userId" TEXT,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"gender" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Attendee_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Attendee_registrationId_idx" ON "Attendee"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Attendee_registrationOptionId_idx" ON "Attendee"("registrationOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Attendee_userId_idx" ON "Attendee"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Ticket_attendeeId_key" ON "Ticket"("attendeeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_holderUserId_idx" ON "Ticket"("holderUserId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormResponse" ADD CONSTRAINT "FormResponse_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ConsentStatus" AS ENUM ('pending', 'given', 'declined');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'consent_override';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'consent_rerequested';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'medical_list_viewed';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Attendee" ADD COLUMN "parentEmail" TEXT,
|
||||
ADD COLUMN "parentName" TEXT,
|
||||
ADD COLUMN "parentPhone" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "consentCollectEmergency" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentCollectMedical" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentCollectPhoto" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentText" TEXT,
|
||||
ADD COLUMN "consentTextVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "requiresParentalConsent" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConsentRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"attendeeId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"status" "ConsentStatus" NOT NULL DEFAULT 'pending',
|
||||
"textVersion" INTEGER NOT NULL,
|
||||
"consentText" TEXT NOT NULL,
|
||||
"parentName" TEXT,
|
||||
"parentEmail" TEXT,
|
||||
"parentPhone" TEXT,
|
||||
"signedName" TEXT,
|
||||
"ip" TEXT,
|
||||
"device" TEXT,
|
||||
"medical" TEXT,
|
||||
"emergency" TEXT,
|
||||
"photoConsent" BOOLEAN,
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"remindersSent" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"medicalPurgedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ConsentRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConsentRequest_tokenHash_key" ON "ConsentRequest"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConsentRequest_attendeeId_createdAt_idx" ON "ConsentRequest"("attendeeId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConsentRequest_eventId_status_idx" ON "ConsentRequest"("eventId", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WaitlistStatus" AS ENUM ('waiting', 'offered', 'claimed', 'expired', 'removed', 'left');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "waitlistEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "waitlistOfferHours" INTEGER NOT NULL DEFAULT 24,
|
||||
ADD COLUMN "waitlistPartialOffers" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WaitlistEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"status" "WaitlistStatus" NOT NULL DEFAULT 'waiting',
|
||||
"offeredQuantity" INTEGER,
|
||||
"offerTokenHash" TEXT,
|
||||
"offeredAt" TIMESTAMP(3),
|
||||
"offerExpiresAt" TIMESTAMP(3),
|
||||
"claimedAt" TIMESTAMP(3),
|
||||
"registrationId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WaitlistEntry_offerTokenHash_key" ON "WaitlistEntry"("offerTokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_eventId_status_position_idx" ON "WaitlistEntry"("eventId", "status", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_eventOptionId_status_idx" ON "WaitlistEntry"("eventOptionId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_variantId_idx" ON "WaitlistEntry"("variantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_userId_idx" ON "WaitlistEntry"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_registrationId_idx" ON "WaitlistEntry"("registrationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_acquired';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_released';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_force_released';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'offline_sync';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TicketUsage" ADD COLUMN "clientScanId" TEXT,
|
||||
ADD COLUMN "offline" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "scanLockId" TEXT,
|
||||
ADD COLUMN "syncedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ScanLock" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"sectionId" TEXT,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
"deviceName" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"lockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"snapshotAt" TIMESTAMP(3),
|
||||
"lastSyncAt" TIMESTAMP(3),
|
||||
"releasedAt" TIMESTAMP(3),
|
||||
"forceReleasedById" TEXT,
|
||||
|
||||
CONSTRAINT "ScanLock_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OfflineSyncReport" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scanLockId" TEXT NOT NULL,
|
||||
"accepted" INTEGER NOT NULL DEFAULT 0,
|
||||
"duplicates" INTEGER NOT NULL DEFAULT 0,
|
||||
"conflicts" JSONB NOT NULL DEFAULT '[]',
|
||||
"afterForceRelease" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"reviewedById" TEXT,
|
||||
|
||||
CONSTRAINT "OfflineSyncReport_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_eventId_releasedAt_idx" ON "ScanLock"("eventId", "releasedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_userId_idx" ON "ScanLock"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_sectionId_idx" ON "ScanLock"("sectionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_forceReleasedById_idx" ON "ScanLock"("forceReleasedById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OfflineSyncReport_scanLockId_idx" ON "OfflineSyncReport"("scanLockId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OfflineSyncReport_reviewedById_idx" ON "OfflineSyncReport"("reviewedById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TicketUsage_clientScanId_key" ON "TicketUsage"("clientScanId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_forceReleasedById_fkey" FOREIGN KEY ("forceReleasedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'ticket_transferred';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "allowTicketTransfer" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TicketTransfer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"fromHolderName" TEXT NOT NULL,
|
||||
"fromUserId" TEXT,
|
||||
"toName" TEXT NOT NULL,
|
||||
"toUserId" TEXT,
|
||||
"byUserId" TEXT,
|
||||
"oldQrCode" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TicketTransfer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketTransfer_ticketId_idx" ON "TicketTransfer"("ticketId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketTransfer_oldQrCode_idx" ON "TicketTransfer"("oldQrCode");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_byUserId_fkey" FOREIGN KEY ("byUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "collectGender" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AllocationSet" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"showOnTicket" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AllocationSet_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AllocationGroup" (
|
||||
"id" TEXT NOT NULL,
|
||||
"setId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"capacity" INTEGER,
|
||||
"gender" TEXT,
|
||||
"leaderUserId" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AllocationGroup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AllocationAssignment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"setId" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"attendeeId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AllocationAssignment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationSet_eventId_idx" ON "AllocationSet"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationGroup_setId_idx" ON "AllocationGroup"("setId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationGroup_leaderUserId_idx" ON "AllocationGroup"("leaderUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationAssignment_groupId_idx" ON "AllocationAssignment"("groupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationAssignment_attendeeId_idx" ON "AllocationAssignment"("attendeeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AllocationAssignment_setId_attendeeId_key" ON "AllocationAssignment"("setId", "attendeeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationSet" ADD CONSTRAINT "AllocationSet_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationGroup" ADD CONSTRAINT "AllocationGroup_setId_fkey" FOREIGN KEY ("setId") REFERENCES "AllocationSet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationGroup" ADD CONSTRAINT "AllocationGroup_leaderUserId_fkey" FOREIGN KEY ("leaderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_setId_fkey" FOREIGN KEY ("setId") REFERENCES "AllocationSet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "AllocationGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "allowSponsorships" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "showSponsorshipProgress" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "sponsorshipGoalSpots" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Payment" ADD COLUMN "isSponsorship" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "sponsorAnonymous" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "sponsorMessage" TEXT,
|
||||
ADD COLUMN "sponsorName" TEXT;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "FormFieldType" ADD VALUE 'rating';
|
||||
ALTER TYPE "FormFieldType" ADD VALUE 'multiple_choice';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackForm" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"sendAt" TIMESTAMP(3),
|
||||
"audience" TEXT NOT NULL DEFAULT 'checked_in',
|
||||
"anonymous" BOOLEAN NOT NULL DEFAULT false,
|
||||
"intro" TEXT,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"reminderSentAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FeedbackForm_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackField" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"type" "FormFieldType" NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"isRequired" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"helpText" TEXT,
|
||||
"options" JSONB,
|
||||
|
||||
CONSTRAINT "FeedbackField_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackInvite" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"remindedAt" TIMESTAMP(3),
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "FeedbackInvite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackResponse" (
|
||||
"id" TEXT NOT NULL,
|
||||
"formId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "FeedbackResponse_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackAnswer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"responseId" TEXT NOT NULL,
|
||||
"fieldId" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "FeedbackAnswer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FeedbackForm_eventId_key" ON "FeedbackForm"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackField_formId_idx" ON "FeedbackField"("formId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FeedbackInvite_tokenHash_key" ON "FeedbackInvite"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackInvite_userId_idx" ON "FeedbackInvite"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FeedbackInvite_formId_userId_key" ON "FeedbackInvite"("formId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackResponse_formId_idx" ON "FeedbackResponse"("formId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackAnswer_responseId_idx" ON "FeedbackAnswer"("responseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackAnswer_fieldId_idx" ON "FeedbackAnswer"("fieldId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackForm" ADD CONSTRAINT "FeedbackForm_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackField" ADD CONSTRAINT "FeedbackField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackInvite" ADD CONSTRAINT "FeedbackInvite_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackInvite" ADD CONSTRAINT "FeedbackInvite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackResponse" ADD CONSTRAINT "FeedbackResponse_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackResponse" ADD CONSTRAINT "FeedbackResponse_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackAnswer" ADD CONSTRAINT "FeedbackAnswer_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "FeedbackResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackAnswer" ADD CONSTRAINT "FeedbackAnswer_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "FeedbackField"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "analyticsSessionId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AnalyticsEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"stage" TEXT NOT NULL,
|
||||
"source" TEXT,
|
||||
"device" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AnalyticsDaily" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"day" DATE NOT NULL,
|
||||
"stage" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL DEFAULT 'direct',
|
||||
"device" TEXT NOT NULL DEFAULT 'unknown',
|
||||
"sessions" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "AnalyticsDaily_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnalyticsEvent_eventId_createdAt_idx" ON "AnalyticsEvent"("eventId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AnalyticsEvent_sessionId_eventId_stage_key" ON "AnalyticsEvent"("sessionId", "eventId", "stage");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnalyticsDaily_eventId_day_idx" ON "AnalyticsDaily"("eventId", "day");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AnalyticsDaily_eventId_day_stage_source_device_key" ON "AnalyticsDaily"("eventId", "day", "stage", "source", "device");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AnalyticsEvent" ADD CONSTRAINT "AnalyticsEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AnalyticsDaily" ADD CONSTRAINT "AnalyticsDaily_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_started';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_ended';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImpersonationSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"adminId" TEXT NOT NULL,
|
||||
"targetUserId" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"endedAt" TIMESTAMP(3),
|
||||
"endedReason" TEXT,
|
||||
|
||||
CONSTRAINT "ImpersonationSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImpersonationRequestLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"blocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ImpersonationRequestLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImpersonationSession_adminId_idx" ON "ImpersonationSession"("adminId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImpersonationSession_targetUserId_idx" ON "ImpersonationSession"("targetUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImpersonationRequestLog_sessionId_createdAt_idx" ON "ImpersonationRequestLog"("sessionId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImpersonationRequestLog" ADD CONSTRAINT "ImpersonationRequestLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ImpersonationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "TicketTransferRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"requestedById" TEXT NOT NULL,
|
||||
"toName" TEXT NOT NULL,
|
||||
"toEmail" TEXT,
|
||||
"toPhone" TEXT,
|
||||
"details" JSONB,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TicketTransferRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TicketTransferRequest_tokenHash_key" ON "TicketTransferRequest"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketTransferRequest_ticketId_status_idx" ON "TicketTransferRequest"("ticketId", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 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 'yoco_config_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_registered';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'yoco_webhook_deleted';
|
||||
|
||||
+751
-223
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,7 @@ function encrypt(plaintext) {
|
||||
return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
|
||||
}
|
||||
|
||||
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
|
||||
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
|
||||
|
||||
// ─── Mapping: env var(s) → DB key ────────────────────────────────────────────
|
||||
// Each entry: { key: DB key, envVars: [...candidates in priority order], encrypt?: bool }
|
||||
@@ -79,6 +79,10 @@ const MAPPINGS = [
|
||||
{ key: 'smtp_user', envVars: ['SMTP_USER', 'EMAIL_USER'], encrypt: true },
|
||||
{ key: 'smtp_pass', envVars: ['SMTP_PASS', 'EMAIL_PASS'], encrypt: true },
|
||||
|
||||
// Yoco payments
|
||||
{ key: 'yoco_secret_key', envVars: ['YOCO_SECRET_KEY'], encrypt: true },
|
||||
{ key: 'yoco_webhook_secret', envVars: ['YOCO_WEBHOOK_SECRET'], encrypt: true },
|
||||
|
||||
// App URLs (used in email templates)
|
||||
{ key: 'app_base_url', envVars: ['FRONTEND_URL', 'APP_BASE_URL'] },
|
||||
];
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const generateToken = (userId, role, tokenVersion = 0) => {
|
||||
// `extra` adds claims, e.g. { setup: true } for the first-run setup wizard's token.
|
||||
const generateToken = (userId, role, tokenVersion = 0, extra = {}) => {
|
||||
return jwt.sign(
|
||||
{ id: userId, role, tokenVersion },
|
||||
{ ...extra, id: userId, role, tokenVersion },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const { getAdminAuditLog } = require('../utils/adminAudit');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
|
||||
// @desc Paginated admin/supervisor action audit trail, with optional filters
|
||||
// @route GET /api/admin/audit-log
|
||||
// @access Admin
|
||||
const listAuditLog = async (req, res) => {
|
||||
try {
|
||||
const { page, limit, actorId, action, from, to } = req.query;
|
||||
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listAuditLog };
|
||||
@@ -0,0 +1,323 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { allocationError, cleanGender, joinBlocker, autoAllocate } = require('../utils/allocation');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const cleanName = (v, what) => {
|
||||
const name = String(v || '').trim();
|
||||
if (!name) throw allocationError(`Enter a name for the ${what}.`);
|
||||
return name.slice(0, 100);
|
||||
};
|
||||
const cleanCapacity = (v) => {
|
||||
if (v === undefined || v === null || v === '') return null;
|
||||
const n = parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > 10000) throw allocationError('Capacity must be a whole number of at least 1.');
|
||||
return n;
|
||||
};
|
||||
|
||||
/** Named attendees on the event's live registrations. */
|
||||
async function eventAttendees(eventId) {
|
||||
return prisma.attendee.findMany({
|
||||
where: { registration: { eventId, status: { not: 'cancelled' } } },
|
||||
select: {
|
||||
id: true, name: true, gender: true, dateOfBirth: true, registrationId: true, email: true, phone: true,
|
||||
registration: { select: { user: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSet(id) {
|
||||
const set = await prisma.allocationSet.findUnique({ where: { id }, include: { groups: { orderBy: { order: 'asc' } } } });
|
||||
if (!set) throw allocationError('Allocation set not found', 404);
|
||||
return set;
|
||||
}
|
||||
|
||||
// @desc Everything the allocation board needs for an event
|
||||
// @route GET /api/allocations/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const getBoard = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: req.params.eventId },
|
||||
select: { id: true, title: true, startDate: true, collectGender: true, allowNamedAttendees: true, requiresParentalConsent: true },
|
||||
});
|
||||
if (!event) throw allocationError('Event not found', 404);
|
||||
const [sets, attendees, leaders, places] = await Promise.all([
|
||||
prisma.allocationSet.findMany({
|
||||
where: { eventId: event.id },
|
||||
include: { groups: { orderBy: { order: 'asc' }, include: { leader: { select: { id: true, name: true } } } }, assignments: { select: { attendeeId: true, groupId: true } } },
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
||||
}),
|
||||
eventAttendees(event.id),
|
||||
prisma.user.findMany({ where: { role: { in: ['staff', 'supervisor', 'admin'] }, isActive: true }, select: { id: true, name: true }, orderBy: { name: 'asc' } }),
|
||||
prisma.registrationOption.aggregate({
|
||||
where: { registration: { eventId: event.id, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
|
||||
_sum: { quantity: true },
|
||||
}),
|
||||
]);
|
||||
res.json({
|
||||
event,
|
||||
sets,
|
||||
attendees: attendees.map(a => ({ id: a.id, name: a.name, gender: a.gender, dateOfBirth: a.dateOfBirth, registrationId: a.registrationId, bookedBy: a.registration.user?.name || '' })),
|
||||
leaders,
|
||||
unnamedPlaces: Math.max(0, (places._sum.quantity || 0) - attendees.length),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/allocations/sets { eventId, name, showOnTicket? }
|
||||
const createSet = async (req, res) => {
|
||||
try {
|
||||
const { eventId, name, showOnTicket } = req.body || {};
|
||||
if (!(await prisma.event.findUnique({ where: { id: String(eventId || '') }, select: { id: true } }))) throw allocationError('Event not found', 404);
|
||||
const order = await prisma.allocationSet.count({ where: { eventId } });
|
||||
res.status(201).json(await prisma.allocationSet.create({ data: { eventId, name: cleanName(name, 'set'), showOnTicket: showOnTicket === true, order } }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/allocations/sets/:id { name?, showOnTicket? }
|
||||
const updateSet = async (req, res) => {
|
||||
try {
|
||||
await loadSet(req.params.id);
|
||||
const { name, showOnTicket } = req.body || {};
|
||||
res.json(await prisma.allocationSet.update({
|
||||
where: { id: req.params.id },
|
||||
data: { ...(name !== undefined ? { name: cleanName(name, 'set') } : {}), ...(showOnTicket !== undefined ? { showOnTicket: showOnTicket === true } : {}) },
|
||||
}));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/allocations/sets/:id
|
||||
const deleteSet = async (req, res) => {
|
||||
try {
|
||||
await loadSet(req.params.id);
|
||||
await prisma.allocationSet.delete({ where: { id: req.params.id } });
|
||||
res.json({ message: 'Deleted' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
async function groupData(body, setId, existing = null) {
|
||||
const { name, capacity, gender, leaderUserId } = body || {};
|
||||
const data = {};
|
||||
if (name !== undefined || !existing) data.name = cleanName(name, 'group');
|
||||
if (capacity !== undefined) data.capacity = cleanCapacity(capacity);
|
||||
if (gender !== undefined) data.gender = cleanGender(gender);
|
||||
if (leaderUserId !== undefined) {
|
||||
if (leaderUserId) {
|
||||
const leader = await prisma.user.findUnique({ where: { id: String(leaderUserId) }, select: { role: true } });
|
||||
if (!leader || !['staff', 'supervisor', 'admin'].includes(leader.role)) throw allocationError('A group leader must be a staff member.');
|
||||
}
|
||||
data.leaderUserId = leaderUserId || null;
|
||||
}
|
||||
// Changing capacity or gender mustn't leave the group over capacity or mixed.
|
||||
if (existing && (data.capacity !== undefined || data.gender !== undefined)) {
|
||||
const members = await prisma.allocationAssignment.findMany({ where: { groupId: existing.id }, include: { attendee: { select: { name: true, gender: true } } } });
|
||||
const next = { ...existing, ...data };
|
||||
const blocker = joinBlocker(next, 0, members.map(m => m.attendee));
|
||||
if (blocker) throw allocationError(`${blocker} Move people out first.`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId? }
|
||||
const createGroup = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
const data = await groupData(req.body, set.id);
|
||||
res.status(201).json(await prisma.allocationGroup.create({ data: { ...data, setId: set.id, order: set.groups.length } }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/allocations/groups/:id
|
||||
const updateGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await prisma.allocationGroup.findUnique({ where: { id: req.params.id } });
|
||||
if (!group) throw allocationError('Group not found', 404);
|
||||
const data = await groupData(req.body, group.setId, group);
|
||||
res.json(await prisma.allocationGroup.update({ where: { id: group.id }, data }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/allocations/groups/:id (its people go back to unassigned)
|
||||
const deleteGroup = async (req, res) => {
|
||||
try {
|
||||
await prisma.allocationGroup.delete({ where: { id: req.params.id } });
|
||||
res.json({ message: 'Deleted' });
|
||||
} catch (error) {
|
||||
if (error.code === 'P2025') return res.status(404).json({ message: 'Group not found' });
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Put an attendee in a group (groupId null = unassign)
|
||||
// @route PUT /api/allocations/sets/:id/assignments { attendeeId, groupId }
|
||||
const assign = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
const { attendeeId, groupId } = req.body || {};
|
||||
const attendee = await prisma.attendee.findFirst({
|
||||
where: { id: String(attendeeId || ''), registration: { eventId: set.eventId, status: { not: 'cancelled' } } },
|
||||
select: { id: true, name: true, gender: true },
|
||||
});
|
||||
if (!attendee) throw allocationError('Attendee not found for this event', 404);
|
||||
if (!groupId) {
|
||||
await prisma.allocationAssignment.deleteMany({ where: { setId: set.id, attendeeId: attendee.id } });
|
||||
return res.json({ message: 'Unassigned' });
|
||||
}
|
||||
const group = set.groups.find(g => g.id === groupId);
|
||||
if (!group) throw allocationError('Group not found in this set', 404);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'alloc:' + group.id}))`;
|
||||
const count = await tx.allocationAssignment.count({ where: { groupId: group.id, NOT: { attendeeId: attendee.id } } });
|
||||
const blocker = joinBlocker(group, count, [attendee]);
|
||||
if (blocker) throw allocationError(blocker);
|
||||
await tx.allocationAssignment.upsert({
|
||||
where: { setId_attendeeId: { setId: set.id, attendeeId: attendee.id } },
|
||||
update: { groupId: group.id },
|
||||
create: { setId: set.id, groupId: group.id, attendeeId: attendee.id },
|
||||
});
|
||||
});
|
||||
res.json({ message: `${attendee.name} → ${group.name}` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Place everyone not yet in a group
|
||||
// @route POST /api/allocations/sets/:id/auto { mode: 'together' | 'split' }
|
||||
const autoAssign = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
if (set.groups.length === 0) throw allocationError('Add some groups first.');
|
||||
const mode = req.body?.mode === 'split' ? 'split' : 'together';
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'allocset:' + set.id}))`;
|
||||
const [attendees, existing] = await Promise.all([
|
||||
eventAttendees(set.eventId),
|
||||
tx.allocationAssignment.findMany({ where: { setId: set.id } }),
|
||||
]);
|
||||
const r = autoAllocate({ groups: set.groups, attendees, assigned: new Map(existing.map(a => [a.attendeeId, a.groupId])), mode });
|
||||
if (r.placements.length) {
|
||||
await tx.allocationAssignment.createMany({ data: r.placements.map(p => ({ setId: set.id, groupId: p.groupId, attendeeId: p.attendeeId })), skipDuplicates: true });
|
||||
}
|
||||
return r;
|
||||
});
|
||||
res.json({
|
||||
placed: result.placements.length,
|
||||
unplaced: result.unplaced.length,
|
||||
message: `${result.placements.length} placed${result.unplaced.length ? `; ${result.unplaced.length} couldn't be placed (groups full, or gender doesn't match)` : ''}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/allocations/sets/:id/assignments (clear the whole set)
|
||||
const clearAssignments = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
const { count } = await prisma.allocationAssignment.deleteMany({ where: { setId: set.id } });
|
||||
res.json({ message: `${count} removed from groups.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
/** Members of groups, with contact and (for supervisors) medical details. */
|
||||
async function groupMembers(groupIds, { withMedical }) {
|
||||
const assignments = await prisma.allocationAssignment.findMany({
|
||||
where: { groupId: { in: groupIds } },
|
||||
include: {
|
||||
attendee: {
|
||||
select: {
|
||||
id: true, name: true, gender: true, dateOfBirth: true, phone: true, parentName: true, parentPhone: true,
|
||||
registration: { select: { user: { select: { name: true, phoneNumber: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { attendee: { name: 'asc' } },
|
||||
});
|
||||
let consent = new Map();
|
||||
if (withMedical && assignments.length) {
|
||||
const { latestRequestsFor, openJson } = require('../utils/consent');
|
||||
const latest = await latestRequestsFor(assignments.map(a => a.attendeeId));
|
||||
consent = new Map([...latest].map(([id, r]) => [id, r.status === 'given' ? { medical: openJson(r.medical), emergency: openJson(r.emergency) } : null]));
|
||||
}
|
||||
return assignments.map(a => ({
|
||||
groupId: a.groupId,
|
||||
id: a.attendee.id,
|
||||
name: a.attendee.name,
|
||||
gender: a.attendee.gender,
|
||||
dateOfBirth: a.attendee.dateOfBirth,
|
||||
phone: a.attendee.phone,
|
||||
parentName: a.attendee.parentName,
|
||||
parentPhone: a.attendee.parentPhone,
|
||||
bookedBy: a.attendee.registration.user?.name || '',
|
||||
bookedByPhone: a.attendee.registration.user?.phoneNumber || null,
|
||||
...(withMedical ? consent.get(a.attendee.id) || {} : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// @desc Lists for printing: every group of a set, with members
|
||||
// @route GET /api/allocations/sets/:id/lists?medical=true
|
||||
// @access Private/Supervisor
|
||||
const getSetLists = async (req, res) => {
|
||||
try {
|
||||
const set = await prisma.allocationSet.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { event: { select: { id: true, title: true, startDate: true } }, groups: { orderBy: { order: 'asc' }, include: { leader: { select: { name: true } } } } },
|
||||
});
|
||||
if (!set) throw allocationError('Allocation set not found', 404);
|
||||
const withMedical = req.query.medical === 'true';
|
||||
const members = await groupMembers(set.groups.map(g => g.id), { withMedical });
|
||||
if (withMedical) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationSet', targetId: set.id, metadata: { eventId: set.eventId, attendees: members.length }, ip: getClientIp(req) });
|
||||
}
|
||||
res.json({ set: { id: set.id, name: set.name }, event: set.event, groups: set.groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, leader: g.leader?.name || null, members: members.filter(m => m.groupId === g.id) })) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Groups the signed-in staff member leads, with their people
|
||||
// @route GET /api/allocations/my-groups
|
||||
// @access Private/Staff
|
||||
const getMyGroups = async (req, res) => {
|
||||
try {
|
||||
const groups = await prisma.allocationGroup.findMany({
|
||||
where: { leaderUserId: req.user.id, set: { event: { endDate: { gte: new Date(Date.now() - 7 * 864e5) } } } },
|
||||
include: { set: { include: { event: { select: { id: true, title: true, startDate: true } } } } },
|
||||
orderBy: [{ set: { event: { startDate: 'asc' } } }, { order: 'asc' }],
|
||||
});
|
||||
const withMedical = SUPERVISORS.includes(req.user.role);
|
||||
const members = await groupMembers(groups.map(g => g.id), { withMedical });
|
||||
if (withMedical && members.length) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationGroup', targetId: null, metadata: { groups: groups.length, attendees: members.length }, ip: getClientIp(req) });
|
||||
}
|
||||
res.json({
|
||||
medicalShown: withMedical,
|
||||
groups: groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, setName: g.set.name, event: g.set.event, members: members.filter(m => m.groupId === g.id) })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup, assign, autoAssign, clearAssignments, getSetLists, getMyGroups };
|
||||
@@ -0,0 +1,44 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { track, funnelFor } = require('../utils/analytics');
|
||||
|
||||
// @desc Record a funnel step (event page view, registration started)
|
||||
// @route POST /api/analytics/track { sessionId, eventId, stage: 'view'|'start', source? }
|
||||
// @access Public — only sent by browsers whose visitor accepted cookies
|
||||
const trackStage = async (req, res) => {
|
||||
try {
|
||||
const { sessionId, eventId, stage, source } = req.body || {};
|
||||
await track({ sessionId, eventId, stage, source, userAgent: req.headers['user-agent'] });
|
||||
} catch (e) {
|
||||
console.warn('[analytics] track failed:', e?.message || e);
|
||||
}
|
||||
// Always a quiet 204 — tracking must never get in a visitor's way.
|
||||
res.status(204).end();
|
||||
};
|
||||
|
||||
// @desc Registration funnel for one or more events
|
||||
// @route GET /api/analytics/funnel?eventIds=a,b&from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
// @access Private/Supervisor
|
||||
const getFunnel = async (req, res) => {
|
||||
try {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
if (eventIds.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
|
||||
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { id: true, title: true } });
|
||||
const total = await funnelFor(eventIds, { from: req.query.from, to: req.query.to });
|
||||
// Per event too, so events can be compared.
|
||||
const perEvent = [];
|
||||
if (events.length > 1) {
|
||||
for (const ev of events) {
|
||||
const f = await funnelFor([ev.id], { from: req.query.from, to: req.query.to });
|
||||
perEvent.push({ eventId: ev.id, title: ev.title, totals: f.totals, conversion: f.conversion });
|
||||
}
|
||||
} else if (events[0]) {
|
||||
perEvent.push({ eventId: events[0].id, title: events[0].title, totals: total.totals, conversion: total.conversion });
|
||||
}
|
||||
res.json({ ...total, perEvent });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { trackStage, getFunnel };
|
||||
@@ -24,7 +24,7 @@ async function scheduleAutomations(req, res) {
|
||||
html: j.html ? String(j.html) : undefined,
|
||||
text: (!j.html && j.text) ? String(j.text) : (j.text ? String(j.text) : undefined),
|
||||
template: 'custom',
|
||||
filter: { status: undefined, attendeeIds: undefined },
|
||||
filter: { status: undefined, attendeeIds: undefined, ...(j.audience === 'sponsors' ? { audience: 'sponsors' } : {}) },
|
||||
};
|
||||
if (j.promoEventId && typeof j.promoEventId === 'string') {
|
||||
payload.promoEventId = j.promoEventId;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { runBackup, listBackups, BACKUP_DIR, BACKUP_FILENAME_RE } = require('../utils/backupUtils');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
|
||||
// @desc List local database backups (newest first)
|
||||
// @route GET /api/backups
|
||||
// @access Admin
|
||||
const getBackups = async (req, res) => {
|
||||
try {
|
||||
const backups = await listBackups();
|
||||
res.json(backups);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Manually trigger a backup now
|
||||
// @route POST /api/backups/run
|
||||
// @access Admin
|
||||
const triggerBackup = async (req, res) => {
|
||||
try {
|
||||
const result = await runBackup();
|
||||
res.status(201).json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Download a backup file
|
||||
// @route GET /api/backups/:filename/download
|
||||
// @access Admin
|
||||
const downloadBackup = async (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
// Reject anything that isn't exactly the shape this app generates, before ever
|
||||
// touching the filesystem — same posture as the event-alias path-safety fix.
|
||||
if (!BACKUP_FILENAME_RE.test(filename)) {
|
||||
res.status(400);
|
||||
throw new Error('Invalid backup filename');
|
||||
}
|
||||
const filePath = path.join(BACKUP_DIR, filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404);
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
res.download(filePath, filename);
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getBackups, triggerBackup, downloadBackup };
|
||||
@@ -0,0 +1,264 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { describeUserAgent } = require('../utils/userAgent');
|
||||
const {
|
||||
isMinor, consentStatusFor, validateConsentAnswer, sealJson, openJson, latestRequestsFor,
|
||||
findRequestByToken, resendConsentRequest, createConsentRequest,
|
||||
} = require('../utils/consent');
|
||||
|
||||
const STAFF = ['admin', 'supervisor', 'staff'];
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
// What the parent sees — never the registrant's contact details or other attendees.
|
||||
function publicView(request) {
|
||||
const ev = request.event;
|
||||
const latestForAttendee = request._isLatest !== false;
|
||||
return {
|
||||
status: request.status,
|
||||
superseded: !latestForAttendee,
|
||||
expired: new Date(request.expiresAt) < new Date(),
|
||||
respondedAt: request.respondedAt,
|
||||
attendeeName: request.attendee.name,
|
||||
parentName: request.parentName,
|
||||
consentText: request.consentText,
|
||||
textVersion: request.textVersion,
|
||||
event: { title: ev.title, startDate: ev.startDate, endDate: ev.endDate, location: ev.location },
|
||||
collect: { medical: ev.consentCollectMedical, emergency: ev.consentCollectEmergency, photo: ev.consentCollectPhoto },
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Consent page data for a parent/guardian
|
||||
// @route GET /api/consent/:token
|
||||
// @access Public (the token is the credential)
|
||||
const getConsent = async (req, res) => {
|
||||
try {
|
||||
const request = await findRequestByToken(req.params.token);
|
||||
if (!request) { res.status(404); throw new Error('This consent link is not valid. It may have been replaced by a newer one — check your latest message.'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
res.json(publicView({ ...request, _isLatest: latest?.id === request.id }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Give or decline consent
|
||||
// @route POST /api/consent/:token
|
||||
// @access Public (the token is the credential)
|
||||
const answerConsent = async (req, res) => {
|
||||
try {
|
||||
const request = await findRequestByToken(req.params.token);
|
||||
if (!request) { res.status(404); throw new Error('This consent link is not valid.'); }
|
||||
if (request.status !== 'pending') { res.status(409); throw new Error(`Consent has already been ${request.status}.`); }
|
||||
if (new Date(request.expiresAt) < new Date()) { res.status(410); throw new Error('This consent link has expired because the event has ended.'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
if (latest?.id !== request.id) { res.status(409); throw new Error('A newer consent request replaced this one — please use the link in your latest message.'); }
|
||||
|
||||
let answer;
|
||||
try { answer = validateConsentAnswer(req.body || {}, request.event); } catch (e) { res.status(400); throw e; }
|
||||
|
||||
// Only flips pending → answered once, even if the form is submitted twice at once.
|
||||
const updated = await prisma.consentRequest.updateMany({
|
||||
where: { id: request.id, status: 'pending' },
|
||||
data: {
|
||||
status: answer.decision === 'give' ? 'given' : 'declined',
|
||||
signedName: answer.signedName,
|
||||
ip: getClientIp(req),
|
||||
device: describeUserAgent(req.headers['user-agent']),
|
||||
medical: sealJson(answer.medical),
|
||||
emergency: sealJson(answer.emergency),
|
||||
photoConsent: answer.photoConsent,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) { res.status(409); throw new Error('Consent has already been answered.'); }
|
||||
|
||||
notifyConsentAnswered(request.id).catch(e => console.error('[consent] notify failed:', e?.message || e));
|
||||
res.json({ message: answer.decision === 'give' ? 'Thank you — consent has been given.' : 'Thank you — consent has been declined.', status: answer.decision === 'give' ? 'given' : 'declined' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Tells the registrant and the event's notify recipients how a parent answered.
|
||||
async function notifyConsentAnswered(requestId) {
|
||||
const request = await prisma.consentRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
});
|
||||
if (!request) return;
|
||||
const { sendMail, emailWrapper } = require('../utils/email');
|
||||
const { shouldEmail, waText } = require('../utils/notify');
|
||||
const { buildEventNotifyRecipientList } = require('../utils/notifications');
|
||||
const given = request.status === 'given';
|
||||
const verb = given ? 'given' : 'declined';
|
||||
const who = request.attendee.name;
|
||||
const ev = request.event.title;
|
||||
const payer = request.attendee.registration.user;
|
||||
const line = given
|
||||
? `${request.signedName} gave parental consent for ${who} for ${ev}.`
|
||||
: `${request.signedName} declined parental consent for ${who} for ${ev}. ${who} can't be checked in — please contact the organisers about a refund.`;
|
||||
|
||||
const sends = [];
|
||||
const payerHasEmail = payer?.email && !payer.email.endsWith('@guest.local') && !payer.email.endsWith('@deleted.invalid');
|
||||
if (payerHasEmail && shouldEmail(payer) && payer.email !== request.parentEmail) {
|
||||
sends.push(sendMail({ to: payer.email, subject: `Consent ${verb} for ${who}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
|
||||
}
|
||||
if (payer) sends.push(waText(payer, line));
|
||||
const staff = buildEventNotifyRecipientList(request.event);
|
||||
if (staff.length) {
|
||||
sends.push(sendMail({ to: staff.join(','), subject: `Consent ${verb}: ${who} — ${ev}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
|
||||
}
|
||||
await Promise.allSettled(sends);
|
||||
}
|
||||
|
||||
/** One row per attendee on the event, with consent state — for the consent tracker. */
|
||||
async function consentRows(eventId) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) return { event: null, rows: [] };
|
||||
const attendees = await prisma.attendee.findMany({
|
||||
where: { registration: { eventId, status: { not: 'cancelled' } } },
|
||||
include: { registration: { select: { id: true, status: true, user: { select: { id: true, name: true, email: true } } } } },
|
||||
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
|
||||
});
|
||||
const latest = await latestRequestsFor(attendees.map(a => a.id));
|
||||
const rows = attendees.map(a => {
|
||||
const r = latest.get(a.id) || null;
|
||||
return {
|
||||
attendeeId: a.id,
|
||||
name: a.name,
|
||||
dateOfBirth: a.dateOfBirth,
|
||||
minor: isMinor(a.dateOfBirth, event.startDate),
|
||||
parentName: a.parentName, parentEmail: a.parentEmail, parentPhone: a.parentPhone,
|
||||
registrationId: a.registrationId,
|
||||
registrationStatus: a.registration.status,
|
||||
bookedBy: a.registration.user,
|
||||
status: consentStatusFor(a, event, r),
|
||||
request: r && {
|
||||
id: r.id, status: r.status, textVersion: r.textVersion, signedName: r.signedName, respondedAt: r.respondedAt,
|
||||
lastSentAt: r.lastSentAt, remindersSent: r.remindersSent, photoConsent: r.photoConsent,
|
||||
outdated: r.status === 'given' && r.textVersion < (event.consentTextVersion || 1),
|
||||
},
|
||||
};
|
||||
});
|
||||
return { event, rows };
|
||||
}
|
||||
|
||||
// @desc Consent status for every attendee on an event
|
||||
// @route GET /api/consent/events/:eventId
|
||||
// @access Private/Staff
|
||||
const listEventConsent = async (req, res) => {
|
||||
try {
|
||||
const { event, rows } = await consentRows(req.params.eventId);
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const counts = rows.reduce((c, r) => { c[r.status] = (c[r.status] || 0) + 1; return c; }, {});
|
||||
res.json({
|
||||
event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion },
|
||||
counts,
|
||||
rows,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-send one consent request (with a fresh link)
|
||||
// @route POST /api/consent/requests/:id/resend
|
||||
// @access Private (the registrant, or staff)
|
||||
const resendRequest = async (req, res) => {
|
||||
try {
|
||||
const request = await prisma.consentRequest.findUnique({ where: { id: req.params.id }, include: { attendee: { include: { registration: true } } } });
|
||||
if (!request) { res.status(404); throw new Error('Consent request not found'); }
|
||||
if (request.attendee.registration.userId !== req.user.id && !STAFF.includes(req.user.role)) { res.status(403); throw new Error('Not authorized'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
if (latest?.id !== request.id) { res.status(400); throw new Error('A newer consent request has replaced this one.'); }
|
||||
const sent = await resendConsentRequest(request.id);
|
||||
res.json({ message: sent.length ? `Consent request re-sent by ${sent.join(' and ')}.` : 'The consent request could not be sent — check the parent\'s email and phone.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-send every consent request still pending on an event
|
||||
// @route POST /api/consent/events/:eventId/resend-pending
|
||||
// @access Private/Supervisor
|
||||
const resendPending = async (req, res) => {
|
||||
try {
|
||||
const { rows } = await consentRows(req.params.eventId);
|
||||
const pending = rows.filter(r => r.status === 'pending' && r.request);
|
||||
let sent = 0;
|
||||
for (const r of pending) {
|
||||
try { if ((await resendConsentRequest(r.request.id)).length) sent += 1; } catch {}
|
||||
}
|
||||
res.json({ message: `Re-sent ${sent} of ${pending.length} pending request(s).`, sent, pending: pending.length });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Ask again for consent where it was given on an older version of the text,
|
||||
// and for minors who never got a request (e.g. names added later)
|
||||
// @route POST /api/consent/events/:eventId/rerequest
|
||||
// @access Private/Supervisor
|
||||
const rerequestConsent = async (req, res) => {
|
||||
try {
|
||||
const { event, rows } = await consentRows(req.params.eventId);
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
if (!event.requiresParentalConsent) { res.status(400); throw new Error('This event does not need parental consent.'); }
|
||||
const targets = rows.filter(r => r.minor && r.registrationStatus !== 'cancelled' && (r.status === 'missing' || r.request?.outdated));
|
||||
let created = 0;
|
||||
for (const r of targets) {
|
||||
if (!r.parentEmail && !r.parentPhone) continue;
|
||||
const attendee = await prisma.attendee.findUnique({ where: { id: r.attendeeId } });
|
||||
await createConsentRequest(attendee, event);
|
||||
created += 1;
|
||||
}
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'consent_rerequested',
|
||||
targetType: 'Event', targetId: event.id, metadata: { requests: created, textVersion: event.consentTextVersion },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ message: `Sent ${created} new consent request(s).`, created });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Medical & emergency list for an event (decrypted) — every view is audited
|
||||
// @route GET /api/consent/events/:eventId/medical
|
||||
// @access Private/Supervisor
|
||||
const medicalList = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId } });
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const { rows } = await consentRows(event.id);
|
||||
const ids = rows.map(r => r.request?.id).filter(Boolean);
|
||||
const full = ids.length ? await prisma.consentRequest.findMany({ where: { id: { in: ids } } }) : [];
|
||||
const byId = new Map(full.map(r => [r.id, r]));
|
||||
const list = rows
|
||||
.filter(r => r.request && byId.get(r.request.id)?.status === 'given')
|
||||
.map(r => {
|
||||
const fr = byId.get(r.request.id);
|
||||
return {
|
||||
name: r.name, dateOfBirth: r.dateOfBirth,
|
||||
parentName: r.parentName, parentPhone: r.parentPhone, parentEmail: r.parentEmail,
|
||||
medical: openJson(fr.medical), emergency: openJson(fr.emergency), photoConsent: fr.photoConsent,
|
||||
purged: !!fr.medicalPurgedAt,
|
||||
};
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed',
|
||||
targetType: 'Event', targetId: event.id, metadata: { attendees: list.length },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ event: { id: event.id, title: event.title, startDate: event.startDate }, rows: list });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList };
|
||||
@@ -0,0 +1,331 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
const COUPON_DETAIL_INCLUDE = {
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true } } } },
|
||||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
};
|
||||
|
||||
const optionalDate = (v, label) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(v);
|
||||
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
|
||||
return d;
|
||||
};
|
||||
|
||||
const optionalInt = (v, label, min = 0) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`);
|
||||
return n;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns a create/update body into Prisma data plus the relation lists. On update,
|
||||
* fields left out of the body are left alone.
|
||||
*/
|
||||
async function parseCouponBody(body, { isCreate }) {
|
||||
const data = {};
|
||||
if (isCreate || body.code !== undefined) {
|
||||
const code = normalizeCode(body.code);
|
||||
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) {
|
||||
throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores');
|
||||
}
|
||||
data.code = code;
|
||||
}
|
||||
if (isCreate || body.type !== undefined) {
|
||||
if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'");
|
||||
data.type = body.type;
|
||||
}
|
||||
if (isCreate || body.value !== undefined) {
|
||||
const type = data.type || body.type;
|
||||
const value = type === 'comp' ? 0 : Number(body.value);
|
||||
if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero');
|
||||
if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100');
|
||||
data.value = value;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
|
||||
if (body.active !== undefined) data.active = !!body.active;
|
||||
const validFrom = optionalDate(body.validFrom, 'Valid from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Valid until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"');
|
||||
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
|
||||
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
|
||||
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
|
||||
if (maxUses !== undefined) data.maxUses = maxUses;
|
||||
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
|
||||
if (minQuantity !== undefined) data.minQuantity = minQuantity;
|
||||
if (body.restrictRoles !== undefined) {
|
||||
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
|
||||
if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions');
|
||||
data.restrictRoles = roles;
|
||||
}
|
||||
if (body.restrictDomain !== undefined) {
|
||||
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
|
||||
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org');
|
||||
data.restrictDomain = domain || null;
|
||||
}
|
||||
|
||||
const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined;
|
||||
const scopes = body.options !== undefined
|
||||
? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
|
||||
: undefined;
|
||||
const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined;
|
||||
|
||||
if (scopes && scopes.length > 0) {
|
||||
const optionRows = await prisma.eventOption.findMany({
|
||||
where: { id: { in: scopes.map(s => s.eventOptionId) } },
|
||||
select: { id: true, eventId: true, variants: { select: { id: true } } },
|
||||
});
|
||||
for (const s of scopes) {
|
||||
const row = optionRows.find(o => o.id === s.eventOptionId);
|
||||
if (!row) throw new Error('One of the selected ticket options no longer exists');
|
||||
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists');
|
||||
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
|
||||
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) {
|
||||
throw new Error('Selected ticket options must belong to the selected events');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, eventIds, scopes, userIds };
|
||||
}
|
||||
|
||||
function relationWrites({ eventIds, scopes, userIds }, isCreate) {
|
||||
const out = {};
|
||||
if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) };
|
||||
if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes };
|
||||
if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) };
|
||||
return out;
|
||||
}
|
||||
|
||||
// @desc List coupons with their use counts
|
||||
// @route GET /api/coupons
|
||||
// @access Private/Supervisor
|
||||
const listCoupons = async (req, res) => {
|
||||
try {
|
||||
const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } });
|
||||
const totals = await prisma.couponRedemption.groupBy({
|
||||
by: ['couponId'],
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||||
_count: { _all: true },
|
||||
_sum: { discountAmount: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
|
||||
res.json(coupons.map(c => ({
|
||||
...c,
|
||||
summary: describeCoupon(c),
|
||||
uses: byId[c.id]?._count?._all || 0,
|
||||
totalDiscount: byId[c.id]?._sum?.discountAmount || 0,
|
||||
})));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get one coupon with its redemptions
|
||||
// @route GET /api/coupons/:id
|
||||
// @access Private/Supervisor
|
||||
const getCoupon = async (req, res) => {
|
||||
try {
|
||||
const coupon = await prisma.coupon.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
...COUPON_DETAIL_INCLUDE,
|
||||
redemptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon) { res.status(404); throw new Error('Coupon not found'); }
|
||||
res.json({ ...coupon, summary: describeCoupon(coupon) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create a coupon
|
||||
// @route POST /api/coupons
|
||||
// @access Private/Supervisor
|
||||
const createCoupon = async (req, res) => {
|
||||
try {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
const coupon = await prisma.coupon.create({
|
||||
data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) },
|
||||
include: COUPON_DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created',
|
||||
targetType: 'Coupon', targetId: coupon.id,
|
||||
metadata: { code: coupon.code, type: coupon.type, value: coupon.value },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update a coupon (including turning it off). Discounts already applied keep
|
||||
// their locked amount.
|
||||
// @route PUT /api/coupons/:id
|
||||
// @access Private/Supervisor
|
||||
const updateCoupon = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Coupon not found'); }
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||||
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
|
||||
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
}
|
||||
const coupon = await prisma.coupon.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...relationWrites(parsed, false) },
|
||||
include: COUPON_DETAIL_INCLUDE,
|
||||
});
|
||||
const disabled = existing.active && coupon.active === false;
|
||||
const changedKeys = Object.keys(req.body || {});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role,
|
||||
action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
|
||||
targetType: 'Coupon', targetId: coupon.id,
|
||||
metadata: { code: coupon.code, changedKeys },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
const { usesTotal } = await countCouponUses(coupon.id, null);
|
||||
res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Check a code against the items someone is about to register for
|
||||
// @route POST /api/coupons/validate
|
||||
// @access Public (login needed for restricted codes)
|
||||
// Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
|
||||
// Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new
|
||||
// attendee — restricted codes are then checked when the registration is saved.
|
||||
const validateCode = async (req, res) => {
|
||||
try {
|
||||
const { code, eventId, options, forUserId } = req.body || {};
|
||||
if (!code || !eventId || !Array.isArray(options) || options.length === 0) {
|
||||
res.status(400); throw new Error('code, eventId and options are required');
|
||||
}
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
||||
});
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
|
||||
const isStaff = req.user && STAFF_ROLES.includes(req.user.role);
|
||||
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
if (isStaff && forUserId) {
|
||||
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
|
||||
} else if (isStaff && forUserId === null) {
|
||||
buyer = null; // a walk-in without an account yet
|
||||
}
|
||||
|
||||
// Price the basket the same way registration will.
|
||||
const registrationOptions = [];
|
||||
for (const o of options) {
|
||||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||||
const qty = Number(o.quantity || 0);
|
||||
if (!eo || qty < 1) continue;
|
||||
const resolved = o.variantId
|
||||
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
|
||||
: await resolveOptionPrice(eo, qty, buyer);
|
||||
registrationOptions.push({
|
||||
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
|
||||
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
|
||||
});
|
||||
}
|
||||
const lines = buildCouponLines({ registrationOptions });
|
||||
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
|
||||
try {
|
||||
const { coupon, discount } = await previewCoupon({
|
||||
code, eventId, user: buyer, lines,
|
||||
deferRestrictions: isStaff && !buyer,
|
||||
});
|
||||
res.json({
|
||||
valid: true,
|
||||
code: coupon.code,
|
||||
type: coupon.type,
|
||||
value: coupon.value,
|
||||
summary: describeCoupon(coupon),
|
||||
description: coupon.description,
|
||||
discount,
|
||||
subtotal,
|
||||
total: Math.max(0, subtotal - discount),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.isCouponError) throw e;
|
||||
res.status(400).json({ valid: false, message: e.message });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Coupon uses for the Discounts report
|
||||
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
|
||||
// @access Private/Supervisor
|
||||
const listRedemptions = async (req, res) => {
|
||||
try {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const from = req.query.from ? new Date(req.query.from) : null;
|
||||
const to = req.query.to ? new Date(req.query.to) : null;
|
||||
const where = {
|
||||
releasedAt: null,
|
||||
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) },
|
||||
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
|
||||
};
|
||||
const rows = await prisma.couponRedemption.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
coupon: { select: { id: true, code: true, type: true, value: true } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
res.json(rows.map(r => ({ ...r, summary: describeCoupon(r.coupon) })));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
|
||||
@@ -3,7 +3,10 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
|
||||
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
|
||||
function toAbsoluteUrl(req, url) {
|
||||
@@ -41,7 +44,7 @@ function toAbsoluteUrl(req, url) {
|
||||
// @access Private/Admin
|
||||
const createEvent = async (req, res) => {
|
||||
try {
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, 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(),
|
||||
@@ -59,14 +62,40 @@ 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,
|
||||
location: location || null,
|
||||
};
|
||||
|
||||
const logEventCreate = (createdEvent) => logAdminAction({
|
||||
actorId: req.user?.id,
|
||||
actorRole: req.user?.role,
|
||||
action: 'event_created',
|
||||
targetType: 'Event',
|
||||
targetId: createdEvent.id,
|
||||
metadata: { title: createdEvent.title },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
try {
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
|
||||
// Automatically create a main ticket (event option) with the event price — contact-only
|
||||
// events have no bookable options, so there's nothing to auto-create for them.
|
||||
@@ -130,24 +159,28 @@ const createEvent = async (req, res) => {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.registrationDeadline;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
if (msg.includes('Unknown argument `goLiveAt`')) {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.goLiveAt;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
if (msg.includes('Unknown argument `createdById`')) {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.createdById;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
if (msg.includes('Unknown argument `redirectUrl`')) {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.redirectUrl;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
throw err;
|
||||
@@ -194,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 };
|
||||
@@ -400,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) };
|
||||
}));
|
||||
|
||||
@@ -442,6 +464,55 @@ const getEventById = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Download a .ics calendar file for an event
|
||||
// @route GET /api/events/:id/ics
|
||||
// @access Public (same visibility gating as getEventById)
|
||||
const getEventIcs = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
|
||||
// details shouldn't be enumerable via this route either.
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
if (!isStaffOrHigher) {
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
let goLiveAt = null;
|
||||
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
|
||||
if (goLiveAt && new Date() < goLiveAt) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
}
|
||||
|
||||
const { buildEventIcs } = require('../utils/icsUtils');
|
||||
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
// The ics library's url validator requires a scheme; guard against FRONTEND_URL/APP_BASE_URL
|
||||
// being configured without one (e.g. "example.com" instead of "https://example.com").
|
||||
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) {
|
||||
frontendUrl = `https://${frontendUrl}`;
|
||||
}
|
||||
const ics = buildEventIcs(event, `${frontendUrl}/events/${event.id}`);
|
||||
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics"`);
|
||||
res.send(ics);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update event
|
||||
// @route PUT /api/events/:id
|
||||
// @access Private/Admin
|
||||
@@ -460,7 +531,17 @@ const updateEvent = async (req, res) => {
|
||||
// totals — same rule already enforced for payments/costs. Admin can reopen first.
|
||||
await assertEventOpen(req.params.id, res);
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const logEventUpdate = () => logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'event_updated',
|
||||
targetType: 'Event',
|
||||
targetId: req.params.id,
|
||||
metadata: { changedKeys: Object.keys(req.body || {}) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
@@ -475,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,
|
||||
@@ -526,6 +627,7 @@ const updateEvent = async (req, res) => {
|
||||
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
|
||||
}
|
||||
|
||||
logEventUpdate();
|
||||
return res.json(updatedEvent);
|
||||
} catch (err) {
|
||||
const msg = String(err?.message || '');
|
||||
@@ -533,12 +635,14 @@ const updateEvent = async (req, res) => {
|
||||
// @ts-ignore
|
||||
delete data.registrationDeadline;
|
||||
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
||||
logEventUpdate();
|
||||
return res.json(updatedEvent);
|
||||
}
|
||||
if (msg.includes('Unknown argument `goLiveAt`')) {
|
||||
// @ts-ignore
|
||||
delete data.goLiveAt;
|
||||
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
||||
logEventUpdate();
|
||||
return res.json(updatedEvent);
|
||||
}
|
||||
throw err;
|
||||
@@ -623,12 +727,30 @@ const deleteEvent = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'event_deleted',
|
||||
targetType: 'Event',
|
||||
targetId: req.params.id,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
res.json({ message: 'Event deactivated' });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Optional staff price from a request body: '' / null clears it, undefined leaves it.
|
||||
function parseStaffPrice(value, current = null) {
|
||||
if (value === undefined) return current;
|
||||
if (value === null || value === '') return null;
|
||||
const n = parseFloat(value);
|
||||
if (!(n >= 0)) throw new Error('Staff price must be zero or more');
|
||||
return n;
|
||||
}
|
||||
|
||||
// @desc Create event option
|
||||
// @route POST /api/events/:id/options
|
||||
// @access Private/Admin
|
||||
@@ -645,7 +767,7 @@ const createEventOption = async (req, res) => {
|
||||
|
||||
await assertEventOpen(req.params.id, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit } = req.body;
|
||||
const { name, price, isMainTicket, stockLimit, staffPrice } = req.body;
|
||||
|
||||
const eventOption = await prisma.eventOption.create({
|
||||
data: {
|
||||
@@ -655,6 +777,7 @@ const createEventOption = async (req, res) => {
|
||||
price: parseFloat(price),
|
||||
isMainTicket: isMainTicket || false,
|
||||
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(staffPrice),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -681,7 +804,7 @@ const updateEventOption = async (req, res) => {
|
||||
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants } = req.body;
|
||||
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice } = req.body;
|
||||
|
||||
const updatedEventOption = await prisma.eventOption.update({
|
||||
where: { id: req.params.id },
|
||||
@@ -690,6 +813,7 @@ const updateEventOption = async (req, res) => {
|
||||
price: price !== undefined ? parseFloat(price) : eventOption.price,
|
||||
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
|
||||
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
|
||||
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -758,10 +882,11 @@ const updateEventOption = async (req, res) => {
|
||||
if (!v.name) continue;
|
||||
const variantPrice = v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null;
|
||||
const variantStockLimit = v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0;
|
||||
const variantStaffPrice = parseStaffPrice(v.staffPrice);
|
||||
if (v.id) {
|
||||
await prisma.optionVariant.update({
|
||||
where: { id: v.id },
|
||||
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, order: i }
|
||||
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, staffPrice: variantStaffPrice, order: i }
|
||||
});
|
||||
} else {
|
||||
await prisma.optionVariant.create({
|
||||
@@ -771,6 +896,7 @@ const updateEventOption = async (req, res) => {
|
||||
name: v.name,
|
||||
price: variantPrice,
|
||||
stockLimit: variantStockLimit,
|
||||
staffPrice: variantStaffPrice,
|
||||
order: i,
|
||||
}
|
||||
});
|
||||
@@ -778,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' } } }
|
||||
@@ -848,7 +978,7 @@ const createOptionVariant = async (req, res) => {
|
||||
const eventOption = await prisma.eventOption.findUnique({ where: { id: req.params.id } });
|
||||
if (!eventOption) { res.status(404); throw new Error('Event option not found'); }
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
const { name, price, stockLimit, order } = req.body;
|
||||
const { name, price, stockLimit, order, staffPrice } = req.body;
|
||||
if (!name) { res.status(400); throw new Error('Variant name is required'); }
|
||||
const variant = await prisma.optionVariant.create({
|
||||
data: {
|
||||
@@ -857,6 +987,7 @@ const createOptionVariant = async (req, res) => {
|
||||
name,
|
||||
price: price !== undefined && price !== null && price !== '' ? parseFloat(price) : null,
|
||||
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(staffPrice),
|
||||
order: order !== undefined ? parseInt(order, 10) : 0,
|
||||
}
|
||||
});
|
||||
@@ -874,11 +1005,12 @@ const updateOptionVariant = async (req, res) => {
|
||||
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
|
||||
if (!variant) { res.status(404); throw new Error('Variant not found'); }
|
||||
await assertEventOpen(variant.eventOption.eventId, res);
|
||||
const { name, price, stockLimit, order } = req.body;
|
||||
const { name, price, stockLimit, order, staffPrice } = req.body;
|
||||
const updated = await prisma.optionVariant.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: name !== undefined ? name : variant.name,
|
||||
staffPrice: parseStaffPrice(staffPrice, variant.staffPrice),
|
||||
price: price !== undefined ? (price === null || price === '' ? null : parseFloat(price)) : variant.price,
|
||||
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : variant.stockLimit,
|
||||
order: order !== undefined ? parseInt(order, 10) : variant.order,
|
||||
@@ -919,7 +1051,10 @@ const attachmentsStorage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const unique = `${Date.now()}-${file.originalname}`;
|
||||
// Extension only — file.originalname is untrusted and joining it into a
|
||||
// path allows `../` traversal to write outside the upload directory.
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const unique = `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
|
||||
cb(null, unique);
|
||||
}
|
||||
});
|
||||
@@ -1204,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) {
|
||||
@@ -1254,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) });
|
||||
}
|
||||
@@ -1442,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 };
|
||||
@@ -1696,9 +1859,20 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
* @route GET /api/events/by-alias/:redirectUrl
|
||||
* @access Public
|
||||
*/
|
||||
// Aliases are admin-set slugs (e.g. "camp-2025") — see the event wizard's "URL
|
||||
// Alias" field. This endpoint is public and also the target of the frontend's
|
||||
// catch-all [redirectUrl] route, so it's what every bot/scanner probe hitting
|
||||
// an unmatched top-level path (/wp-login.php, /.env, etc.) ends up calling.
|
||||
// Rejecting non-slug-shaped values here skips a DB round-trip for that traffic.
|
||||
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
|
||||
const getEventByAlias = async (req, res) => {
|
||||
const { redirectUrl } = req.params;
|
||||
|
||||
if (!VALID_ALIAS.test(redirectUrl)) {
|
||||
return res.status(404).json({ message: 'Event not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await prisma.event.findFirst({
|
||||
where: {
|
||||
@@ -1747,6 +1921,7 @@ module.exports = {
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
getEventById,
|
||||
getEventIcs,
|
||||
updateEvent,
|
||||
getEventNotifyRecipients,
|
||||
updateEventNotifyRecipients,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, sendFeedbackInvites, findInviteByToken } = require('../utils/feedback');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
const shape = (fields) => JSON.stringify(fields.map(f => [f.type, f.label, f.isRequired, f.options || null, f.helpText || null]));
|
||||
|
||||
async function formWithStats(eventId) {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { eventId }, include: { fields: { orderBy: { order: 'asc' } } } });
|
||||
if (!form) return null;
|
||||
const [invited, responses] = await Promise.all([
|
||||
prisma.feedbackInvite.count({ where: { formId: form.id } }),
|
||||
prisma.feedbackResponse.count({ where: { formId: form.id } }),
|
||||
]);
|
||||
return { ...form, invited, responses };
|
||||
}
|
||||
|
||||
// @desc An event's feedback form (with invite/response counts)
|
||||
// @route GET /api/feedback/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const getEventFeedback = async (req, res) => {
|
||||
try {
|
||||
res.json(await formWithStats(req.params.eventId));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create or update an event's feedback form
|
||||
// @route PUT /api/feedback/event/:eventId { sendAt, audience, anonymous, intro, fields[] }
|
||||
// @access Private/Supervisor
|
||||
const saveEventFeedback = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, select: { id: true, endDate: true } });
|
||||
if (!event) throw feedbackError('Event not found', 404);
|
||||
const { sendAt, audience, anonymous, intro, fields } = req.body || {};
|
||||
const clean = cleanFields(fields);
|
||||
let when = null;
|
||||
if (sendAt) {
|
||||
when = new Date(sendAt);
|
||||
if (isNaN(when.getTime())) throw feedbackError('Enter a valid send time.');
|
||||
}
|
||||
const data = {
|
||||
sendAt: when,
|
||||
audience: audience === 'all' ? 'all' : 'checked_in',
|
||||
anonymous: anonymous === true,
|
||||
intro: intro ? String(intro).slice(0, 2000) : null,
|
||||
};
|
||||
|
||||
const existing = await prisma.feedbackForm.findUnique({ where: { eventId: event.id }, include: { fields: { orderBy: { order: 'asc' } } } });
|
||||
if (!existing) {
|
||||
await prisma.feedbackForm.create({ data: { ...data, eventId: event.id, fields: { create: clean } } });
|
||||
} else {
|
||||
const answered = await prisma.feedbackResponse.count({ where: { formId: existing.id } });
|
||||
const changed = shape(existing.fields) !== shape(clean);
|
||||
if (answered > 0 && changed) throw feedbackError('People have already answered this feedback form, so its questions can’t be changed.', 409);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.feedbackForm.update({ where: { id: existing.id }, data: existing.sentAt ? { ...data, sendAt: existing.sendAt } : data });
|
||||
if (changed) {
|
||||
await tx.feedbackField.deleteMany({ where: { formId: existing.id } });
|
||||
await tx.feedbackField.createMany({ data: clean.map(f => ({ ...f, formId: existing.id, options: f.options ?? undefined })) });
|
||||
}
|
||||
});
|
||||
}
|
||||
res.json(await formWithStats(event.id));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/feedback/event/:eventId
|
||||
const deleteEventFeedback = async (req, res) => {
|
||||
try {
|
||||
await prisma.feedbackForm.deleteMany({ where: { eventId: req.params.eventId } });
|
||||
res.json({ message: 'Feedback form removed' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send the feedback form now (instead of waiting for its send time)
|
||||
// @route POST /api/feedback/event/:eventId/send-now
|
||||
// @access Private/Supervisor
|
||||
const sendNow = async (req, res) => {
|
||||
try {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { eventId: req.params.eventId } });
|
||||
if (!form) throw feedbackError('This event has no feedback form.', 404);
|
||||
if (!form.sentAt) await prisma.feedbackForm.update({ where: { id: form.id }, data: { sentAt: new Date(), sendAt: form.sendAt || new Date() } });
|
||||
// Also invites anyone who checked in after the first send.
|
||||
const invited = await sendFeedbackInvites(form.id);
|
||||
res.json({ invited, message: invited ? `Feedback form sent to ${invited} ${invited === 1 ? 'person' : 'people'}.` : 'Everyone has already been sent the form.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The form behind a personal feedback link
|
||||
// @route GET /api/feedback/public/:token
|
||||
// @access Public (link holder)
|
||||
const getPublicForm = async (req, res) => {
|
||||
try {
|
||||
const invite = await findInviteByToken(req.params.token);
|
||||
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
|
||||
const { form } = invite;
|
||||
res.json({
|
||||
event: form.event,
|
||||
name: invite.user?.name || '',
|
||||
intro: form.intro,
|
||||
anonymous: form.anonymous,
|
||||
responded: !!invite.respondedAt,
|
||||
fields: form.fields.map(f => ({ id: f.id, type: f.type, label: f.label, isRequired: f.isRequired, helpText: f.helpText, options: f.options })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Submit feedback (once per person)
|
||||
// @route POST /api/feedback/public/:token { answers: { fieldId: value } }
|
||||
// @access Public (link holder)
|
||||
const submitPublicForm = async (req, res) => {
|
||||
try {
|
||||
const invite = await findInviteByToken(req.params.token);
|
||||
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
|
||||
if (invite.respondedAt) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
|
||||
const answers = req.body?.answers || {};
|
||||
const rows = [];
|
||||
for (const f of invite.form.fields.filter(x => ANSWERABLE.includes(x.type))) {
|
||||
const value = cleanAnswer(f, answers[f.id]);
|
||||
if (value !== null) rows.push({ fieldId: f.id, value });
|
||||
}
|
||||
if (rows.length === 0) throw feedbackError('Please answer at least one question.');
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Claim the invite first, so a double-submit can't create two responses.
|
||||
const claimed = await tx.feedbackInvite.updateMany({ where: { id: invite.id, respondedAt: null }, data: { respondedAt: new Date() } });
|
||||
if (claimed.count !== 1) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
|
||||
await tx.feedbackResponse.create({
|
||||
data: { formId: invite.formId, userId: invite.form.anonymous ? null : invite.userId, answers: { create: rows } },
|
||||
});
|
||||
});
|
||||
res.status(201).json({ message: 'Thank you for your feedback!' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Feedback summary for one or more events (for the report, and comparisons)
|
||||
// @route GET /api/feedback/summary?eventIds=a,b
|
||||
// @access Private/Supervisor
|
||||
const getSummary = async (req, res) => {
|
||||
try {
|
||||
const ids = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
const forms = await prisma.feedbackForm.findMany({
|
||||
where: { eventId: { in: ids } },
|
||||
include: {
|
||||
event: { select: { id: true, title: true, startDate: true } },
|
||||
fields: { orderBy: { order: 'asc' } },
|
||||
responses: { include: { answers: true, user: { select: { name: true } } } },
|
||||
_count: { select: { invites: true } },
|
||||
},
|
||||
});
|
||||
const out = forms.map(form => {
|
||||
const answers = form.responses.flatMap(r => r.answers.map(a => ({ fieldId: a.fieldId, value: a.value, ...(form.anonymous ? {} : { name: r.user?.name || '' }) })));
|
||||
const invited = form._count.invites;
|
||||
return {
|
||||
eventId: form.event.id, title: form.event.title, startDate: form.event.startDate,
|
||||
anonymous: form.anonymous, sentAt: form.sentAt,
|
||||
invited, responses: form.responses.length,
|
||||
responseRate: invited ? Math.round((form.responses.length / invited) * 100) : null,
|
||||
questions: summarise(form.fields, answers),
|
||||
};
|
||||
}).sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary };
|
||||
@@ -0,0 +1,108 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { MAX_MINUTES, impersonationBlocker, logRequest } = require('../utils/impersonation');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
const err = (message, statusCode = 400) => Object.assign(new Error(message), { statusCode });
|
||||
|
||||
// @desc Start viewing the site as a user (read-only, 30 minutes)
|
||||
// @route POST /api/admin/impersonate/:userId { reason }
|
||||
// @access Private/Admin (with 2FA)
|
||||
const startImpersonation = async (req, res) => {
|
||||
try {
|
||||
if (req.readOnly) throw err('End the current impersonation first.', 403);
|
||||
const reason = String(req.body?.reason || '').trim();
|
||||
if (reason.length < 5) throw err('Give a reason (at least a few words) — it is recorded in the audit log.');
|
||||
const target = await prisma.user.findUnique({ where: { id: req.params.userId }, select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true } });
|
||||
const blocker = impersonationBlocker(req.user, target);
|
||||
if (blocker) throw err(blocker, target ? 403 : 404);
|
||||
|
||||
// One live session per admin.
|
||||
await prisma.impersonationSession.updateMany({ where: { adminId: req.user.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'replaced' } });
|
||||
const expiresAt = new Date(Date.now() + MAX_MINUTES * 60 * 1000);
|
||||
const session = await prisma.impersonationSession.create({ data: { adminId: req.user.id, targetUserId: target.id, reason: reason.slice(0, 500), expiresAt } });
|
||||
const token = jwt.sign({ id: target.id, role: target.role, tokenVersion: target.tokenVersion, imp: session.id }, process.env.JWT_SECRET, { expiresIn: `${MAX_MINUTES}m` });
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id,
|
||||
metadata: { sessionId: session.id, reason: session.reason, targetName: target.name, expiresAt }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
// Optional: tell the user (Site Settings → Security).
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
if (getSettingSync('notify_user_on_impersonation', 'false') === 'true' && target.email && !target.email.endsWith('@guest.local')) {
|
||||
const { sendMail } = require('../utils/email');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
sendMail({
|
||||
to: target.email,
|
||||
subject: `An administrator viewed your account — ${orgName}`,
|
||||
text: `Hi ${target.name || 'there'},\n\nAn administrator at ${orgName} viewed your account (read-only) to help with support.\nReason given: ${session.reason}\n\nNothing was changed. If you have questions, reply to this email.\n\n${orgName}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
res.status(201).json({ token, session: { id: session.id, expiresAt, target: { id: target.id, name: target.name, email: target.email, role: target.role } } });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc End impersonation (called with the impersonation token)
|
||||
// @route POST /api/admin/impersonate/end
|
||||
// @access Impersonation token
|
||||
const endImpersonation = async (req, res) => {
|
||||
try {
|
||||
const sessionId = req.impersonation?.id;
|
||||
if (!sessionId) throw err('Not impersonating.', 400);
|
||||
const s = await prisma.impersonationSession.update({ where: { id: sessionId }, data: { endedAt: new Date(), endedReason: 'ended' } });
|
||||
const count = await prisma.impersonationRequestLog.count({ where: { sessionId } });
|
||||
logAdminAction({
|
||||
actorId: s.adminId, actorRole: 'admin', action: 'impersonation_ended', targetType: 'User', targetId: s.targetUserId,
|
||||
metadata: { sessionId, minutes: Math.round((Date.now() - s.startedAt.getTime()) / 60000), requests: count }, ip: getClientIp(req),
|
||||
});
|
||||
res.json({ message: 'Impersonation ended.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Record a page the admin viewed while impersonating
|
||||
// @route POST /api/admin/impersonate/log { path }
|
||||
// @access Impersonation token
|
||||
const logPageView = async (req, res) => {
|
||||
if (req.impersonation?.id) logRequest(req.impersonation.id, 'PAGE', String(req.body?.path || '').slice(0, 500));
|
||||
res.status(204).end();
|
||||
};
|
||||
|
||||
// @desc The current impersonation session (for the banner)
|
||||
// @route GET /api/admin/impersonate/current
|
||||
// @access Impersonation token
|
||||
const currentImpersonation = async (req, res) => {
|
||||
const s = req.impersonation;
|
||||
if (!s) return res.json(null);
|
||||
res.json({ id: s.id, expiresAt: s.expiresAt, target: { id: req.user.id, name: req.user.name, email: req.user.email } });
|
||||
};
|
||||
|
||||
// @desc Impersonation sessions with their request trail (audit log page)
|
||||
// @route GET /api/admin/impersonation-sessions
|
||||
// @access Private/Admin
|
||||
const listSessions = async (req, res) => {
|
||||
try {
|
||||
const sessions = await prisma.impersonationSession.findMany({
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: Math.min(parseInt(req.query.limit, 10) || 50, 200),
|
||||
include: {
|
||||
admin: { select: { name: true, email: true } },
|
||||
target: { select: { name: true, email: true } },
|
||||
requests: { orderBy: { createdAt: 'asc' }, take: 500, select: { method: true, path: true, blocked: true, createdAt: true } },
|
||||
},
|
||||
});
|
||||
res.json(sessions);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions };
|
||||
@@ -2,7 +2,11 @@ const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||
const { computeDonationRemaining } = require('../utils/donationUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const axios = require('axios');
|
||||
const { yocoAuthHeader } = require('../utils/yoco');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
@@ -611,17 +615,12 @@ const assignDonationToRegistration = async (req, res) => {
|
||||
throw new Error('Only donations can be assigned to registrations');
|
||||
}
|
||||
|
||||
// Donations are never mutated once created — their remaining balance is the original
|
||||
// amount minus every leg (a Payment row with isDonation:false and originalPaymentId
|
||||
// pointing back at this donation) already allocated from it. A refund of the donation
|
||||
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces
|
||||
// the remaining balance (money that's left the building) instead of increasing it (which a
|
||||
// raw signed sum would do, since subtracting a negative adds).
|
||||
// See computeDonationRemaining's doc comment for why refund legs (negative amount) reduce
|
||||
// rather than inflate the remaining balance.
|
||||
const existingLegs = await prisma.payment.findMany({
|
||||
where: { originalPaymentId: payment.id, isDonation: false }
|
||||
});
|
||||
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
|
||||
const remainingDonation = payment.amount - alreadyUsed;
|
||||
const remainingDonation = computeDonationRemaining(payment.amount, existingLegs);
|
||||
|
||||
if (remainingDonation <= 0.000001) {
|
||||
res.status(400);
|
||||
@@ -766,6 +765,16 @@ const assignDonationToRegistration = async (req, res) => {
|
||||
donationRemaining: remainingDonation - allocateAmount
|
||||
};
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'donation_assigned',
|
||||
targetType: 'Registration',
|
||||
targetId: registrationId,
|
||||
metadata: { paymentId, legId: leg.id, allocateAmount },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -885,6 +894,16 @@ const unassignDonationFromRegistration = async (req, res) => {
|
||||
catch (e) { console.error('Failed to send emails after unassigning donation:', e); }
|
||||
})();
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'donation_unassigned',
|
||||
targetType: 'Registration',
|
||||
targetId: leg.registrationId,
|
||||
metadata: { legId: leg.id, donationId: donation.id, amount: leg.amount },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Donation unassigned',
|
||||
updatedRegistration: finalRegistration,
|
||||
@@ -895,6 +914,25 @@ const unassignDonationFromRegistration = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Yoco line items for a registration: one per item at each price actually charged
|
||||
* (tranche-aware, so a line bought partly at early-bird shows both prices), plus the
|
||||
* coupon discount as a negative line. Previous/partial payment lines are added by callers.
|
||||
*/
|
||||
function checkoutLineItems(registration) {
|
||||
const { buildLineItems, couponDiscountLine } = require('../utils/pdfDocs');
|
||||
const items = buildLineItems(registration.registrationOptions).map(row => ({
|
||||
displayName: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
|
||||
quantity: row.quantity,
|
||||
pricingDetails: { price: Math.round(row.unitPrice * 100) },
|
||||
}));
|
||||
const coupon = couponDiscountLine(registration);
|
||||
if (coupon) {
|
||||
items.push({ displayName: coupon.label, quantity: 1, pricingDetails: { price: -Math.round(coupon.amount * 100) } });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
|
||||
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
|
||||
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
|
||||
@@ -909,6 +947,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: true
|
||||
}
|
||||
@@ -924,26 +963,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
|
||||
const amountInCents = Math.round(remainingAmount * 100);
|
||||
const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`;
|
||||
const now = new Date();
|
||||
|
||||
const lastPaymentAt = registration.payments.length > 0
|
||||
? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())))
|
||||
: null;
|
||||
|
||||
const lineItems = registration.registrationOptions.map(option => {
|
||||
const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : '';
|
||||
// Use priceSnapshot when available (variant-aware, authoritative); fall back to deadline-only check
|
||||
const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined)
|
||||
? option.priceSnapshot
|
||||
: require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now);
|
||||
return {
|
||||
displayName: `${option.eventOption.name}${variantLabel}`,
|
||||
quantity: option.quantity,
|
||||
pricingDetails: {
|
||||
price: Math.round(unitPrice * 100)
|
||||
}
|
||||
};
|
||||
});
|
||||
const lineItems = checkoutLineItems(registration);
|
||||
|
||||
// Show prior payments as a deduction line
|
||||
if (totalPaid > 0) {
|
||||
@@ -968,7 +988,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
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': idempotencyKey } }
|
||||
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
|
||||
);
|
||||
|
||||
await prisma.registration.update({
|
||||
@@ -1047,6 +1067,7 @@ const createYocoCheckout = async (req, res) => {
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: true
|
||||
}
|
||||
@@ -1060,21 +1081,7 @@ const createYocoCheckout = async (req, res) => {
|
||||
if (remainingAmount >= 15 && requested < 15) { res.status(400); throw new Error('Minimum payment is R15'); }
|
||||
const charge = Math.min(requested, remainingAmount);
|
||||
const amountInCents = Math.round(charge * 100);
|
||||
const now = new Date();
|
||||
const lastPaymentAt = registration.payments.length > 0
|
||||
? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())))
|
||||
: null;
|
||||
const lineItems = registration.registrationOptions.map(option => {
|
||||
const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : '';
|
||||
const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined)
|
||||
? option.priceSnapshot
|
||||
: require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now);
|
||||
return {
|
||||
displayName: `${option.eventOption.name}${variantLabel}`,
|
||||
quantity: option.quantity,
|
||||
pricingDetails: { price: Math.round(unitPrice * 100) }
|
||||
};
|
||||
});
|
||||
const lineItems = checkoutLineItems(registration);
|
||||
// Previous payments deduction
|
||||
if (totalPaid > 0) {
|
||||
lineItems.push({ displayName: 'Previous Payments', quantity: 1, pricingDetails: { price: -Math.round(totalPaid * 100) } });
|
||||
@@ -1084,7 +1091,7 @@ const createYocoCheckout = async (req, res) => {
|
||||
lineItems.push({ displayName: 'Partial Payment', quantity: 1, pricingDetails: { price: -Math.round((remainingAmount - charge) * 100) } });
|
||||
}
|
||||
const checkoutData = { amount: amountInCents, currency: 'ZAR', metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' }, successUrl, cancelUrl, failureUrl, lineItems };
|
||||
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': `reg_${registrationId}_${amountInCents}_${Date.now()}` } });
|
||||
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, { headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': `reg_${registrationId}_${amountInCents}_${Date.now()}` } });
|
||||
await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: response.data.id, updatedAt: new Date() } });
|
||||
return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } });
|
||||
}
|
||||
@@ -1145,7 +1152,7 @@ const createYocoCheckout = async (req, res) => {
|
||||
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': idempotencyKey } }
|
||||
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -1157,7 +1164,7 @@ const createYocoCheckout = async (req, res) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Yoco checkout error:', error.response?.data || error.message);
|
||||
res.status(error.response?.status || 400).json({
|
||||
res.status(error.statusCode || error.response?.status || 400).json({
|
||||
message: error.response?.data?.message || error.message
|
||||
});
|
||||
}
|
||||
@@ -1357,6 +1364,16 @@ const createRefund = async (req, res) => {
|
||||
const { sendRefundEmail } = require('../utils/notifications');
|
||||
sendRefundEmail(negativePayment.id).catch(e => console.error('Failed to send refund email:', e));
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'refund_created',
|
||||
targetType: 'Payment',
|
||||
targetId: negativePayment.id,
|
||||
metadata: { amount: amt, method: method || 'refund', reason: reason || null, registrationId: linkRegistrationId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
return res.status(201).json(negativePayment);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
@@ -4,8 +4,14 @@ const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache');
|
||||
const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
|
||||
// Keys safe to return without auth — includes legal keys needed by public legal pages
|
||||
// Yoco secrets are only readable (masked) and writable through /api/yoco — never
|
||||
// through the generic settings endpoints.
|
||||
const YOCO_PRIVATE_KEYS = new Set(['yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id']);
|
||||
|
||||
const PUBLIC_KEYS = [
|
||||
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
|
||||
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
|
||||
@@ -42,6 +48,7 @@ const getAllSettings = async (req, res) => {
|
||||
const rows = await prisma.appSetting.findMany();
|
||||
const settings = {};
|
||||
for (const r of rows) {
|
||||
if (YOCO_PRIVATE_KEYS.has(r.key)) continue;
|
||||
if (ENCRYPTED_KEYS.has(r.key)) {
|
||||
// Return a sentinel so the UI knows the value is set, without exposing it.
|
||||
// smtp_user (email address) we can safely return as-is after decryption so
|
||||
@@ -77,7 +84,7 @@ const updateSettings = async (req, res) => {
|
||||
}
|
||||
|
||||
const ops = Object.entries(updates)
|
||||
.filter(([, v]) => v !== undefined && v !== null)
|
||||
.filter(([k, v]) => v !== undefined && v !== null && !YOCO_PRIVATE_KEYS.has(k))
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
|
||||
@@ -107,6 +114,20 @@ const updateSettings = async (req, res) => {
|
||||
if (ops.length) await prisma.$transaction(ops);
|
||||
invalidateSettingsCache();
|
||||
await warmCache(); // ensure in-memory cache reflects the new values before responding
|
||||
|
||||
// Log which keys changed, never the values — some settings are secrets (e.g. the
|
||||
// WAWP token) that aren't even encrypted at rest, let alone fit for an audit log.
|
||||
if (ops.length) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'settings_updated',
|
||||
targetType: 'AppSetting',
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !YOCO_PRIVATE_KEYS.has(k)) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: 'Settings saved' });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
@@ -156,7 +177,9 @@ const setupRegister = async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0);
|
||||
// Marked as a setup token so the 2FA requirement doesn't block the rest of the
|
||||
// wizard; the admin enrols at their first normal login.
|
||||
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0, { setup: true });
|
||||
res.json({ token, name: user.name, email: user.email });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
@@ -177,7 +200,7 @@ const runSetup = async (req, res) => {
|
||||
|
||||
const toSave = { ...settings, setup_complete: 'true' };
|
||||
const ops = Object.entries(toSave)
|
||||
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
|
||||
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !YOCO_PRIVATE_KEYS.has(k))
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
const axios = require('axios');
|
||||
const { yocoAuthHeader } = require('../utils/yoco');
|
||||
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: await yocoAuthHeader(), 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
|
||||
});
|
||||
res.json({ redirectUrl: response.data.redirectUrl, checkoutId: response.data.id, amount: charge.amount });
|
||||
} catch (error) {
|
||||
if (error.response) console.error('Sponsor checkout error:', error.response.data || error.message);
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The sponsorship pool for an event (supervisors)
|
||||
// @route GET /api/sponsorships/event/:eventId/pool
|
||||
// @access Private/Supervisor
|
||||
const getPool = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
|
||||
if (!event) throw sponsorshipError('Event not found', 404);
|
||||
res.json({ eventId: event.id, spotPrice: spotPrice(event), goalSpots: event.sponsorshipGoalSpots, ...(await poolFor(event)) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getEventSponsorship, createSponsorCheckout, getPool, poolFor };
|
||||
@@ -267,7 +267,21 @@ const getOverviewStats = async (req, res) => {
|
||||
ticketsSold: ticketSumById[r.eventId] || 0,
|
||||
}));
|
||||
|
||||
// Funnel conversion (event page views → paid) across live events, last 30 days.
|
||||
let funnel = null;
|
||||
try {
|
||||
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } });
|
||||
if (live.length) {
|
||||
const { funnelFor } = require('../utils/analytics');
|
||||
const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() });
|
||||
if (f.totals.view > 0) funnel = { views: f.totals.view, paid: f.totals.paid, conversion: f.conversion.paid };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[stats] funnel tile failed:', e?.message || e);
|
||||
}
|
||||
|
||||
res.json({
|
||||
funnel,
|
||||
activeEvents,
|
||||
registrations: { thisMonth: thisRegistrations, lastMonth: lastRegistrations, pctChange: pctChange(thisRegistrations, lastRegistrations) },
|
||||
ticketsSold: { thisMonth: thisTicketsSold, lastMonth: lastTicketsSold, pctChange: pctChange(thisTicketsSold, lastTicketsSold) },
|
||||
|
||||
@@ -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,7 +95,15 @@ 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,
|
||||
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
|
||||
usages: {
|
||||
include: {
|
||||
scannedBy: {
|
||||
@@ -261,6 +270,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 +286,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 +316,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 +360,9 @@ const scanTicket = async (req, res) => {
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
|
||||
const transfer = await findTransferByOldCode(req.params.qrCode);
|
||||
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
@@ -363,6 +384,27 @@ const scanTicket = async (req, res) => {
|
||||
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
|
||||
}
|
||||
|
||||
// Section checks used to happen only in the browser; enforce them here too.
|
||||
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
|
||||
if (sectionId) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
|
||||
if (!section || section.eventId !== ticket.eventId) {
|
||||
return res.status(400).json({ message: 'Section not found for this event', ticket });
|
||||
}
|
||||
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
|
||||
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
|
||||
}
|
||||
}
|
||||
|
||||
// A section (or the whole event) locked to another device for offline scanning
|
||||
// can't be scanned online — those scans would be invisible to the offline device.
|
||||
const { blockingLock } = require('../utils/scanLocks');
|
||||
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
|
||||
if (lock) {
|
||||
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
|
||||
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
|
||||
}
|
||||
|
||||
// Compute how many have already been redeemed
|
||||
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
||||
@@ -375,6 +417,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 +493,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 +704,147 @@ const sendTicketsTo = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Offer a ticket to someone else: sends them a link to accept (nothing changes until they do)
|
||||
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }
|
||||
// @access Private (payer or holder; supervisors always)
|
||||
const transferTicketHandler = async (req, res) => {
|
||||
try {
|
||||
const { requestTransfer } = require('../utils/ticketTransfer');
|
||||
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
|
||||
const { request, sent } = await requestTransfer({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
|
||||
res.status(201).json({
|
||||
message: sent.length
|
||||
? `We've sent ${request.toName} a link by ${sent.join(' and ')} to accept the ticket. It moves to them once they accept — until then it's still yours.`
|
||||
: `The transfer is waiting for ${request.toName} to accept, but the link couldn't be sent. Check the email or phone number.`,
|
||||
requestId: request.id,
|
||||
expiresAt: request.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Withdraw a transfer that hasn't been accepted yet
|
||||
// @route DELETE /api/tickets/:id/transfer
|
||||
// @access Private (payer or holder; supervisors)
|
||||
const cancelTransferHandler = async (req, res) => {
|
||||
try {
|
||||
const { cancelTransfer } = require('../utils/ticketTransfer');
|
||||
await cancelTransfer(req.params.id, req.user);
|
||||
res.json({ message: 'Transfer cancelled — the ticket is still yours.' });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc What's on offer behind a transfer link
|
||||
// @route GET /api/tickets/transfer-offer/:token
|
||||
// @access Public (link holder)
|
||||
const getTransferOffer = async (req, res) => {
|
||||
try {
|
||||
const { findRequestByToken } = require('../utils/ticketTransfer');
|
||||
const r = await findRequestByToken(req.params.token);
|
||||
if (!r) return res.status(404).json({ message: 'This transfer link isn’t valid.' });
|
||||
const t = r.ticket;
|
||||
res.json({
|
||||
status: r.status, toName: r.toName, expiresAt: r.expiresAt,
|
||||
fromName: (t.attendee?.name || t.user?.name || r.requestedBy.name || '').split(' ')[0],
|
||||
event: t.event,
|
||||
option: t.registrationOption.variant ? `${t.registrationOption.eventOption.name} — ${t.registrationOption.variant.name}` : t.registrationOption.eventOption.name,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/tickets/transfer-offer/:token/decline
|
||||
const declineTransferOffer = async (req, res) => {
|
||||
try {
|
||||
const { declineTransfer } = require('../utils/ticketTransfer');
|
||||
await declineTransfer(req.params.token);
|
||||
res.json({ message: 'You declined the ticket. Nothing else will happen.' });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Accept a transferred ticket — the transfer happens now
|
||||
// @route POST /api/tickets/transfer-offer/:token/accept
|
||||
// @access Public (link holder)
|
||||
const acceptTransferOffer = async (req, res) => {
|
||||
try {
|
||||
const { acceptTransfer } = require('../utils/ticketTransfer');
|
||||
const r = await acceptTransfer(req.params.token);
|
||||
const actor = r.request.requestedBy;
|
||||
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
|
||||
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode, acceptedVia: 'link' },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
// Tell the new holder: activation link for a new account, their ticket, and
|
||||
// anything they need to do themselves (consent, attendee form).
|
||||
if (r.accountCreated) {
|
||||
const { sendActivationLink } = require('./userController');
|
||||
sendActivationLink(r.toUser);
|
||||
}
|
||||
const a = r.attendee;
|
||||
const hasEmail = !!(a.email && a.email.includes('@'));
|
||||
const recipient = { id: r.toUser.id, name: a.name, email: hasEmail ? a.email : null, phoneNumber: a.phone || null, notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp' };
|
||||
const silent = { status: () => silent, json: () => silent };
|
||||
emailTicketsInternal({ user: { id: r.target.userId }, body: { ticketIds: [r.target.id] }, _noSplit: true }, silent, recipient)
|
||||
.catch(e => console.error('[transfer] ticket delivery failed:', e?.message || e));
|
||||
if (r.needsConsent) {
|
||||
const { createConsentRequest } = require('../utils/consent');
|
||||
createConsentRequest(a, r.event).catch(e => console.error('[transfer] consent request failed:', e?.message || e));
|
||||
}
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: r.event.id }, include: { fields: { select: { id: true } } } });
|
||||
if (form && form.fields.length > 0) {
|
||||
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
|
||||
const full = await prisma.attendee.findUnique({ where: { id: a.id }, include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } } });
|
||||
sendAttendeeFormLinkMessage(full).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `The ticket is yours! We've sent it to you${r.needsConsent ? ', and asked your parent or guardian for consent' : ''}.${r.accountCreated ? ' We have also sent you a link to activate your account, where you can see your tickets.' : ''}`,
|
||||
ticketId: r.target.id,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Transfer history of a ticket
|
||||
// @route GET /api/tickets/:id/transfers
|
||||
// @access Private (payer or holder; staff)
|
||||
const getTicketTransfers = async (req, res) => {
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id }, select: { userId: true, holderUserId: true } });
|
||||
if (!ticket) { res.status(404); throw new Error('Ticket not found'); }
|
||||
const staffRole = ['admin', 'supervisor', 'staff'].includes(req.user.role);
|
||||
if (!staffRole && ticket.userId !== req.user.id && ticket.holderUserId !== req.user.id) { res.status(403); throw new Error('Not authorized'); }
|
||||
const transfers = await prisma.ticketTransfer.findMany({
|
||||
where: { ticketId: req.params.id },
|
||||
select: { id: true, fromHolderName: true, toName: true, createdAt: true, byUser: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(transfers);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/** "Room: Cabin 3 · Bus: Bus 2" for the sets shown on tickets, or ''. */
|
||||
function allocationLabel(attendee) {
|
||||
return [...(attendee?.allocations || [])]
|
||||
.sort((a, b) => (a.set?.order || 0) - (b.set?.order || 0))
|
||||
.map(a => `${a.set.name}: ${a.group.name}`)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// Internal helper used by both emailTickets and sendTicketsTo
|
||||
async function emailTicketsInternal(req, res, userOverride) {
|
||||
const { ticketIds, registrationId } = req.body;
|
||||
@@ -660,6 +864,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 +880,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 +968,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 +1043,11 @@ module.exports = {
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
getScanStats,
|
||||
transferTicketHandler,
|
||||
cancelTransferHandler,
|
||||
getTransferOffer,
|
||||
acceptTransferOffer,
|
||||
declineTransferOffer,
|
||||
getTicketTransfers
|
||||
};
|
||||
@@ -1,7 +1,15 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
|
||||
// Builds a filename multer can never be tricked into escaping the upload
|
||||
// directory with — extension only, no attacker-controlled path segments.
|
||||
// (file.originalname is untrusted; joining it into a path allows `../` traversal.)
|
||||
function safeFilename(prefix, ext) {
|
||||
return `${prefix}-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
|
||||
}
|
||||
|
||||
// Setup multer storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
@@ -24,8 +32,7 @@ const storage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueName = `${Date.now()}-${file.originalname}`;
|
||||
cb(null, uniqueName);
|
||||
cb(null, safeFilename('event', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,7 +61,7 @@ const logoStorage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `logo-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
|
||||
cb(null, safeFilename('logo', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -82,7 +89,7 @@ const faviconStorage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `favicon-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
|
||||
cb(null, safeFilename('favicon', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,18 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const {
|
||||
METHODS: TWO_FACTOR_METHODS,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
} = require('../utils/twoFactor');
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -124,6 +136,35 @@ async function sendWelcomeEmail(user) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fields the login flow needs once the password (and any 2FA code) has checked out.
|
||||
const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true,
|
||||
};
|
||||
|
||||
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
|
||||
// the controller convention of res.status(...) before throwing.
|
||||
function errorStatus(error, res) {
|
||||
return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
|
||||
}
|
||||
|
||||
// Final step of a successful login (after the password, and the 2FA code when
|
||||
// required): notify, record the activity, and issue the session token.
|
||||
function completeLogin(user, req, res, extra = {}) {
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(user, req).catch(() => {});
|
||||
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion),
|
||||
...extra
|
||||
});
|
||||
}
|
||||
|
||||
// @desc Register a new user
|
||||
// @route POST /api/users
|
||||
// @access Public
|
||||
@@ -198,7 +239,7 @@ const registerUser = async (req, res) => {
|
||||
// @access Public
|
||||
const loginUser = async (req, res) => {
|
||||
try {
|
||||
const {email, password} = req.body;
|
||||
const {email, password, deviceToken} = req.body;
|
||||
|
||||
const rawInput = email;
|
||||
|
||||
@@ -278,20 +319,179 @@ const loginUser = async (req, res) => {
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedAttempts: 0, lockUntil: null },
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(updated, req).catch(() => {});
|
||||
logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
// Two-factor: the password alone isn't enough unless this browser was
|
||||
// remembered earlier. Send a code and hand back a challenge instead of a token.
|
||||
if (updated.twoFactorEnabled && !(await isTrustedDevice(updated.id, deviceToken))) {
|
||||
const challenge = await createChallenge(updated, 'login', updated.twoFactorMethod || 'email');
|
||||
return res.json({ twoFactorRequired: true, ...challenge });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
email: updated.email,
|
||||
role: updated.role,
|
||||
token: generateToken(updated.id, updated.role, updated.tokenVersion)
|
||||
// Admins and supervisors must use 2FA. Not set up yet: send a code to the
|
||||
// first channel that can deliver one, and verifying it turns 2FA on. A
|
||||
// trusted device can't skip this — nothing has been verified yet.
|
||||
if (!updated.twoFactorEnabled && requiresTwoFactor(updated)) {
|
||||
const method = await enforcementMethod(updated);
|
||||
if (method) {
|
||||
const challenge = await createChallenge(updated, 'login', method);
|
||||
return res.json({ twoFactorRequired: true, enrolling: true, ...challenge });
|
||||
}
|
||||
}
|
||||
|
||||
completeLogin(updated, req, res);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Second login step — exchange an emailed/WhatsApp code for a token
|
||||
// @route POST /api/users/login/2fa
|
||||
// @access Public
|
||||
const verifyLoginTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId, code, rememberDevice } = req.body || {};
|
||||
const challenge = await verifyChallenge(challengeId, code, 'login');
|
||||
|
||||
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401);
|
||||
throw new Error('Your account has been deactivated');
|
||||
}
|
||||
|
||||
// First login since 2FA became required for this role: the code proves the
|
||||
// channel works, so turn 2FA on with it.
|
||||
if (!user.twoFactorEnabled && requiresTwoFactor(user)) {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
const extra = {};
|
||||
if (rememberDevice === true) {
|
||||
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
|
||||
}
|
||||
completeLogin(user, req, res, extra);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send a new code for a pending login challenge
|
||||
// @route POST /api/users/login/2fa/resend
|
||||
// @access Public
|
||||
const resendLoginTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId } = req.body || {};
|
||||
res.json(await resendChallenge(challengeId, 'login'));
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Start turning on 2FA — sends a code to the chosen channel to prove it works
|
||||
// @route POST /api/users/2fa/enable
|
||||
// @access Private
|
||||
const startEnableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { method, password } = req.body || {};
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(400);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
if (!TWO_FACTOR_METHODS.includes(method)) {
|
||||
res.status(400);
|
||||
throw new Error('Choose email or WhatsApp for your verification codes');
|
||||
}
|
||||
if (!(await availableMethods(user)).includes(method)) {
|
||||
res.status(400);
|
||||
throw new Error(method === 'whatsapp'
|
||||
? 'WhatsApp codes aren\'t available for your account. Add a valid phone number or choose email.'
|
||||
: 'Add a valid email address to your profile before turning on two-factor authentication.');
|
||||
}
|
||||
|
||||
// Unlike login, don't fall back to email here — the point of this step is to
|
||||
// prove the channel the user picked actually reaches them.
|
||||
res.json(await createChallenge(user, 'enable', method, { allowFallback: false }));
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Finish turning on 2FA with the code that was sent
|
||||
// @route POST /api/users/2fa/confirm
|
||||
// @access Private
|
||||
const confirmEnableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId, code } = req.body || {};
|
||||
const challenge = await verifyChallenge(challengeId, code, 'enable', req.user.id);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
|
||||
});
|
||||
logSecurityEvent({ userId: req.user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({ twoFactorEnabled: true, twoFactorMethod: challenge.method });
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Turn off 2FA
|
||||
// @route POST /api/users/2fa/disable
|
||||
// @access Private
|
||||
const disableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
if (requiresTwoFactor(user)) {
|
||||
res.status(403);
|
||||
throw new Error("Two-factor authentication is required for your role and can't be turned off.");
|
||||
}
|
||||
|
||||
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(400);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorEnabled: false, twoFactorMethod: null, updatedAt: new Date() },
|
||||
});
|
||||
await clearTrustedDevices(user.id);
|
||||
if (user.twoFactorEnabled) {
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({ twoFactorEnabled: false, twoFactorMethod: null });
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-confirm the logged-in user's password without starting a new session
|
||||
// (the self-service kiosk uses this before letting the event be changed)
|
||||
// @route POST /api/users/verify-password
|
||||
// @access Private
|
||||
const verifyPassword = async (req, res) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id }, select: { password: true } });
|
||||
if (!user || typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(401);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -311,6 +511,8 @@ const getUserProfile = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
@@ -318,7 +520,7 @@ const getUserProfile = async (req, res) => {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
res.json(user);
|
||||
res.json({ ...user, twoFactorRequired: requiresTwoFactor(user) });
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
@@ -375,6 +577,11 @@ const updateUserProfile = async (req, res) => {
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
|
||||
|
||||
// 2FA codes can't go to WhatsApp without a usable number — move them to email
|
||||
// rather than leave the account pointing at a channel that no longer exists.
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
|
||||
|
||||
// Update user data
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
@@ -384,6 +591,7 @@ const updateUserProfile = async (req, res) => {
|
||||
password: newHashedPassword ? newHashedPassword : user.password,
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
twoFactorMethod: newTwoFactorMethod,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
@@ -393,6 +601,8 @@ const updateUserProfile = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
@@ -402,6 +612,7 @@ const updateUserProfile = async (req, res) => {
|
||||
|
||||
// If the password was changed, send a security alert email (fire-and-forget)
|
||||
if (newHashedPassword) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
|
||||
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
|
||||
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
|
||||
@@ -414,6 +625,7 @@ const updateUserProfile = async (req, res) => {
|
||||
|
||||
res.json({
|
||||
...updatedUser,
|
||||
twoFactorRequired: requiresTwoFactor(updatedUser),
|
||||
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -432,7 +644,7 @@ const getUsers = async (req, res) => {
|
||||
|
||||
const select = {
|
||||
id: true, name: true, email: true, role: true,
|
||||
phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, createdAt: true, updatedAt: true, isActive: true
|
||||
};
|
||||
|
||||
// Build filters
|
||||
@@ -559,7 +771,7 @@ const updateUser = async (req, res) => {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body;
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled } = req.body;
|
||||
|
||||
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
@@ -580,6 +792,21 @@ const updateUser = async (req, res) => {
|
||||
data.password = await hashPassword(password.trim());
|
||||
}
|
||||
|
||||
// Admins can switch 2FA off for someone who has lost access to their codes,
|
||||
// but never on — enabling requires the user to prove the channel themselves.
|
||||
const resetTwoFactor = twoFactorEnabled === false && user.twoFactorEnabled;
|
||||
if (resetTwoFactor) {
|
||||
data.twoFactorEnabled = false;
|
||||
data.twoFactorMethod = null;
|
||||
}
|
||||
|
||||
// Promoted to a role that requires 2FA without having it: end their sessions
|
||||
// so the next login enrols them.
|
||||
const willHaveTwoFactor = resetTwoFactor ? false : user.twoFactorEnabled;
|
||||
if (!willHaveTwoFactor && data.role !== user.role && requiresTwoFactor({ role: data.role })) {
|
||||
data.tokenVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
@@ -590,12 +817,20 @@ const updateUser = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (resetTwoFactor || data.password) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
}
|
||||
if (resetTwoFactor) {
|
||||
logSecurityEvent({ userId: updatedUser.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -646,10 +881,14 @@ const anonymizeUser = async (req, res) => {
|
||||
email: `deleted-${req.params.id}@deleted.local`,
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.params.id);
|
||||
|
||||
res.json({ message: 'User data deleted' });
|
||||
} catch (error) {
|
||||
@@ -754,6 +993,7 @@ const resetPassword = async (req, res) => {
|
||||
prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }),
|
||||
prisma.passwordReset.update({ where: { token }, data: { used: true } })
|
||||
]);
|
||||
await clearTrustedDevices(user.id);
|
||||
|
||||
logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
@@ -838,6 +1078,7 @@ const revokeMySession = async (req, res) => {
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
select: { id: true, role: true, tokenVersion: true },
|
||||
});
|
||||
await clearTrustedDevices(updated.id);
|
||||
// Bumping tokenVersion invalidates every existing token, including the one
|
||||
// this request just used. Issue a fresh token for the current device so it
|
||||
// isn't logged out too.
|
||||
@@ -864,6 +1105,7 @@ const adminRevokeUserSessions = async (req, res) => {
|
||||
where: { id: req.params.id },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
res.json({ message: `Sessions revoked for ${target.name}.` });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -901,10 +1143,14 @@ const closeAccount = async (req, res) => {
|
||||
password: '',
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.user.id);
|
||||
// Send closure confirmation to the real address (before it was wiped)
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
@@ -926,6 +1172,7 @@ const closeAccount = async (req, res) => {
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
// Send closure confirmation
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
@@ -957,6 +1204,12 @@ const getMyActivity = async (req, res) => {
|
||||
module.exports = {
|
||||
registerUser,
|
||||
loginUser,
|
||||
verifyLoginTwoFactor,
|
||||
resendLoginTwoFactor,
|
||||
startEnableTwoFactor,
|
||||
confirmEnableTwoFactor,
|
||||
disableTwoFactor,
|
||||
verifyPassword,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { remainingFor } = require('../utils/stock');
|
||||
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken } = require('../utils/waitlist');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const ENTRY_INCLUDE = {
|
||||
event: { select: { id: true, title: true, startDate: true, picture: true } },
|
||||
eventOption: { select: { id: true, name: true } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
};
|
||||
|
||||
// @desc Join the waitlist for a sold-out option
|
||||
// @route POST /api/waitlist
|
||||
// @access Public (login needed when the event requires it)
|
||||
// Body: { eventId, eventOptionId, variantId?, quantity, guestName?, guestEmail?, guestPhone? }
|
||||
const joinWaitlist = async (req, res) => {
|
||||
try {
|
||||
const { eventId, eventOptionId, variantId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
|
||||
const qty = parseInt(quantity, 10);
|
||||
if (!eventId || !eventOptionId || !(qty >= 1)) { res.status(400); throw new Error('eventId, eventOptionId and a quantity of at least 1 are required'); }
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } } } });
|
||||
if (!event || !event.isActive) { res.status(404); throw new Error('Event not found'); }
|
||||
if (!event.waitlistEnabled) { res.status(400); throw new Error('This event does not have a waitlist.'); }
|
||||
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) { res.status(400); throw new Error('This event is no longer taking registrations.'); }
|
||||
const option = event.eventOptions.find(o => o.id === eventOptionId);
|
||||
if (!option) { res.status(400); throw new Error('Unknown ticket option for this event'); }
|
||||
const variant = variantId ? (option.variants || []).find(v => v.id === variantId) : null;
|
||||
if (variantId && !variant) { res.status(400); throw new Error('Unknown variant for this option'); }
|
||||
|
||||
const free = await remainingFor(option, variant);
|
||||
if (free === null || free >= qty) { res.status(400); throw new Error('There are places available — you can register now.'); }
|
||||
|
||||
// Who's joining
|
||||
let userId = req.user?.id;
|
||||
if (!userId) {
|
||||
if (event.requiresAuth !== false) { res.status(401); throw new Error('Log in to join the waitlist for this event.'); }
|
||||
if (!guestName || !guestEmail) { res.status(400); throw new Error('Name and email are required to join the waitlist'); }
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) { res.status(409); throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.'); }
|
||||
userId = match.user?.id || (await prisma.user.create({
|
||||
data: { id: uuidv4(), name: String(guestName).trim(), email: String(guestEmail).trim(), password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() },
|
||||
})).id;
|
||||
}
|
||||
|
||||
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, status: { in: ACTIVE } } });
|
||||
if (existing) { res.status(409); throw new Error('You\'re already on the waitlist for this.'); }
|
||||
|
||||
const last = await prisma.waitlistEntry.aggregate({ where: { eventId }, _max: { position: true } });
|
||||
const entry = await prisma.waitlistEntry.create({
|
||||
data: { eventId, eventOptionId, variantId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
|
||||
include: ENTRY_INCLUDE,
|
||||
});
|
||||
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, status: 'waiting', position: { lt: entry.position } } });
|
||||
res.status(201).json({ ...entry, ahead, message: `You're on the waitlist${ahead ? ` — ${ahead} ahead of you` : ''}. We'll email or WhatsApp you if a place opens up.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The caller's waitlist entries
|
||||
// @route GET /api/waitlist/mine
|
||||
// @access Private
|
||||
const myWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entries = await prisma.waitlistEntry.findMany({
|
||||
where: { userId: req.user.id, status: { in: ACTIVE }, event: { endDate: { gte: new Date() } } },
|
||||
include: ENTRY_INCLUDE,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Leave the waitlist (an open offer is passed on)
|
||||
// @route DELETE /api/waitlist/:id
|
||||
// @access Private (owner)
|
||||
const leaveWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry || entry.userId !== req.user.id) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('You\'re no longer on this waitlist.'); }
|
||||
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'left', offerTokenHash: null } });
|
||||
if (entry.status === 'offered') queueWaitlist(entry.eventId);
|
||||
res.json({ message: 'You\'ve left the waitlist.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc What a claim link offers
|
||||
// @route GET /api/waitlist/claim/:token
|
||||
// @access Public (the token is the credential)
|
||||
const claimInfo = async (req, res) => {
|
||||
try {
|
||||
const entry = await findOfferByToken(req.params.token);
|
||||
if (!entry) { res.status(404); throw new Error('This waitlist link is not valid or has already been used.'); }
|
||||
const expired = entry.status !== 'offered' || new Date(entry.offerExpiresAt) < new Date();
|
||||
res.json({
|
||||
status: expired ? 'expired' : 'offered',
|
||||
eventId: entry.eventId,
|
||||
eventTitle: entry.event.title,
|
||||
eventStart: entry.event.startDate,
|
||||
eventOptionId: entry.eventOptionId,
|
||||
optionName: entry.eventOption.name,
|
||||
variantId: entry.variantId,
|
||||
variantName: entry.variant?.name || null,
|
||||
quantity: entry.offeredQuantity,
|
||||
requested: entry.quantity,
|
||||
expiresAt: entry.offerExpiresAt,
|
||||
name: entry.user?.name,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The waitlist for an event
|
||||
// @route GET /api/waitlist/events/:eventId
|
||||
// @access Private/Staff
|
||||
const eventWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entries = await prisma.waitlistEntry.findMany({
|
||||
where: { eventId: req.params.eventId, ...(req.query.all === 'true' ? {} : { status: { in: [...ACTIVE, 'claimed', 'expired'] } }) },
|
||||
include: { ...ENTRY_INCLUDE, user: { select: { id: true, name: true, email: true, phoneNumber: true } } },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Offer a place to someone on the waitlist now (even without free stock)
|
||||
// @route POST /api/waitlist/:id/offer
|
||||
// @access Private/Supervisor
|
||||
const offerManually = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!['waiting', 'expired'].includes(entry.status)) { res.status(400); throw new Error(`This person is ${entry.status === 'offered' ? 'already holding an offer' : `no longer waiting (${entry.status})`}.`); }
|
||||
const qty = Math.max(1, Math.min(entry.quantity, parseInt(req.body?.quantity, 10) || entry.quantity));
|
||||
const updated = await makeOffer(entry.id, qty);
|
||||
res.json({ ...updated, message: `Offered ${qty} place(s); the claim link has been sent.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Move someone up or down the queue
|
||||
// @route POST /api/waitlist/:id/move { direction: 'up' | 'down' }
|
||||
// @access Private/Supervisor
|
||||
const moveEntry = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
const up = req.body?.direction === 'up';
|
||||
const neighbour = await prisma.waitlistEntry.findFirst({
|
||||
where: { eventId: entry.eventId, status: 'waiting', position: up ? { lt: entry.position } : { gt: entry.position } },
|
||||
orderBy: { position: up ? 'desc' : 'asc' },
|
||||
});
|
||||
if (!neighbour) return res.json({ message: 'Already at the ' + (up ? 'front.' : 'back.') });
|
||||
await prisma.$transaction([
|
||||
prisma.waitlistEntry.update({ where: { id: entry.id }, data: { position: neighbour.position } }),
|
||||
prisma.waitlistEntry.update({ where: { id: neighbour.id }, data: { position: entry.position } }),
|
||||
]);
|
||||
res.json({ message: 'Moved.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Take someone off the waitlist (an open offer is passed on)
|
||||
// @route POST /api/waitlist/:id/remove
|
||||
// @access Private/Supervisor
|
||||
const removeEntry = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('This person is not on the waitlist any more.'); }
|
||||
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'removed', offerTokenHash: null } });
|
||||
if (entry.status === 'offered') queueWaitlist(entry.eventId);
|
||||
res.json({ message: 'Removed from the waitlist.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry };
|
||||
@@ -4,6 +4,7 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||
const { getYocoWebhookSecret } = require('../utils/yoco');
|
||||
|
||||
// @desc Handle Yoco webhook events
|
||||
// @route POST /api/webhooks/yoco
|
||||
@@ -54,10 +55,10 @@ const handleYocoWebhook = async (req, res) => {
|
||||
// Construct the signed content
|
||||
const signedContent = `${id}.${timestamp}.${requestBody}`;
|
||||
|
||||
// Get the webhook secret from environment variables
|
||||
const secret = process.env.YOCO_WEBHOOK_SECRET;
|
||||
// Webhook secret from Site Settings → Yoco (falls back to .env)
|
||||
const secret = await getYocoWebhookSecret();
|
||||
if (!secret) {
|
||||
console.error('YOCO_WEBHOOK_SECRET is not defined in environment variables');
|
||||
console.error('Yoco webhook secret is not configured (Site Settings → Yoco, or YOCO_WEBHOOK_SECRET)');
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: 'Server configuration error',
|
||||
@@ -325,7 +326,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: {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { encrypt } = require('../utils/encryption');
|
||||
const { invalidate: invalidateSettingsCache, warmCache, getSetting } = require('../utils/settingsCache');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const {
|
||||
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
|
||||
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
|
||||
maskSecret, yocoMode, listWebhooks, registerWebhook, deleteWebhook,
|
||||
defaultWebhookUrl, webhookUrlProblem,
|
||||
} = require('../utils/yoco');
|
||||
|
||||
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
|
||||
|
||||
// Write settings (null/'' clears), then refresh the cache so the new values apply immediately.
|
||||
async function saveSettings(values) {
|
||||
const ops = Object.entries(values).map(([key, value]) => {
|
||||
const stored = value ? (key === KEY_WEBHOOK_ID ? value : encrypt(value)) : '';
|
||||
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
|
||||
});
|
||||
await prisma.$transaction(ops);
|
||||
invalidateSettingsCache();
|
||||
await warmCache();
|
||||
}
|
||||
|
||||
const audit = (req, action, metadata) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'AppSetting', metadata, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
// @desc Yoco configuration with secrets masked
|
||||
// @route GET /api/yoco/config
|
||||
// @access Admin
|
||||
const getConfig = async (req, res) => {
|
||||
try {
|
||||
const [secretKey, webhookSecret, webhookId, source] = await Promise.all([
|
||||
getYocoSecretKey(), getYocoWebhookSecret(), getSetting(KEY_WEBHOOK_ID), getYocoConfigSource(),
|
||||
]);
|
||||
res.json({
|
||||
secretKeyMasked: maskSecret(secretKey),
|
||||
secretKeySource: source.secretKey,
|
||||
mode: yocoMode(secretKey),
|
||||
webhookSecretMasked: maskSecret(webhookSecret),
|
||||
webhookSecretSource: source.webhookSecret,
|
||||
webhookId: webhookId || '',
|
||||
webhookUrl: defaultWebhookUrl(req),
|
||||
configured: !!(secretKey && webhookSecret),
|
||||
});
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save the secret key and/or webhook secret ('' clears)
|
||||
// @route PUT /api/yoco/config
|
||||
// @access Admin
|
||||
const updateConfig = async (req, res) => {
|
||||
try {
|
||||
const { secretKey, webhookSecret } = req.body || {};
|
||||
const updates = {};
|
||||
|
||||
if (typeof secretKey === 'string') {
|
||||
const key = secretKey.trim();
|
||||
if (key && !/^sk_(test|live)_\S+$/.test(key)) {
|
||||
res.status(400); throw new Error('The secret key should start with sk_test_ or sk_live_.');
|
||||
}
|
||||
// Check Yoco accepts the key before replacing a working one.
|
||||
if (key) await listWebhooks(key);
|
||||
updates[KEY_SECRET] = key;
|
||||
}
|
||||
|
||||
if (typeof webhookSecret === 'string') {
|
||||
const secret = webhookSecret.trim();
|
||||
if (secret && !/^whsec_[A-Za-z0-9+/=]+$/.test(secret)) {
|
||||
res.status(400); throw new Error('The webhook secret should start with whsec_.');
|
||||
}
|
||||
updates[KEY_WEBHOOK] = secret;
|
||||
// A typed-in secret isn't tied to a webhook we registered.
|
||||
updates[KEY_WEBHOOK_ID] = '';
|
||||
}
|
||||
|
||||
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
|
||||
|
||||
await saveSettings(updates);
|
||||
audit(req, 'yoco_config_updated', { changedKeys: Object.keys(updates) });
|
||||
res.json({ message: 'Yoco settings saved' });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Webhooks registered on the Yoco account
|
||||
// @route GET /api/yoco/webhooks
|
||||
// @access Admin
|
||||
const getWebhooks = async (req, res) => {
|
||||
try {
|
||||
const [hooks, activeId] = await Promise.all([listWebhooks(), getSetting(KEY_WEBHOOK_ID)]);
|
||||
const here = defaultWebhookUrl(req);
|
||||
res.json({
|
||||
webhooks: hooks.map(h => ({ ...h, isActive: !!activeId && h.id === activeId, pointsHere: !!here && h.url === here })),
|
||||
defaultUrl: here,
|
||||
});
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Register a webhook at Yoco and store the secret it returns
|
||||
// @route POST /api/yoco/webhooks
|
||||
// @access Admin
|
||||
const createWebhook = async (req, res) => {
|
||||
try {
|
||||
const name = String(req.body?.name || '').trim() || 'hope-events';
|
||||
const url = String(req.body?.url || '').trim() || defaultWebhookUrl(req);
|
||||
const problem = webhookUrlProblem(url);
|
||||
if (problem) { res.status(400); throw new Error(problem); }
|
||||
|
||||
const hook = await registerWebhook(name, url);
|
||||
if (!hook?.secret) throw new Error('Yoco did not return a webhook secret.');
|
||||
|
||||
await saveSettings({ [KEY_WEBHOOK]: hook.secret, [KEY_WEBHOOK_ID]: hook.id });
|
||||
audit(req, 'yoco_webhook_registered', { webhookId: hook.id, url: hook.url, mode: hook.mode });
|
||||
res.status(201).json({
|
||||
message: 'Webhook registered and its secret saved',
|
||||
webhook: { id: hook.id, name: hook.name, url: hook.url, mode: hook.mode },
|
||||
webhookSecretMasked: maskSecret(hook.secret),
|
||||
});
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete a webhook at Yoco (clears the stored secret if it was the active one)
|
||||
// @route DELETE /api/yoco/webhooks/:id
|
||||
// @access Admin
|
||||
const removeWebhook = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await deleteWebhook(id);
|
||||
const wasActive = (await getSetting(KEY_WEBHOOK_ID)) === id;
|
||||
if (wasActive) await saveSettings({ [KEY_WEBHOOK]: '', [KEY_WEBHOOK_ID]: '' });
|
||||
audit(req, 'yoco_webhook_deleted', { webhookId: id, clearedSecret: wasActive });
|
||||
res.json({ message: wasActive ? 'Webhook deleted and its secret removed' : 'Webhook deleted', clearedSecret: wasActive });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook };
|
||||
+420
-9
@@ -1,16 +1,33 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { version: API_VERSION } = require('../package.json');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const dotenv = require('dotenv');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
|
||||
const getRawBody = require('raw-body');
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Error monitoring — a no-op if SENTRY_DSN isn't set, so this is safe in every
|
||||
// environment (dev, a fresh deploy that hasn't configured Sentry yet, etc.).
|
||||
// Must run before express/@prisma/client are required below — Sentry's
|
||||
// auto-instrumentation patches those modules via a require hook, which only
|
||||
// works if Sentry.init() runs before they're first required into the cache.
|
||||
if (process.env.SENTRY_DSN) {
|
||||
const Sentry = require('@sentry/node');
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
// Small single-VM deployment, not high-traffic — start conservative and raise
|
||||
// this once real usage is visible in Sentry, rather than sampling every request.
|
||||
tracesSampleRate: 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
const express = require('express');
|
||||
const { version: API_VERSION } = require('../package.json');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
|
||||
const getRawBody = require('raw-body');
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -18,6 +35,22 @@ const prisma = new PrismaClient();
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Trust proxy — required when running behind a reverse proxy (e.g. nginx on a
|
||||
// separate server) so req.ip / X-Forwarded-For are read correctly by
|
||||
// express-rate-limit and friends. Accepts a hop count ("1"), "true"/"false",
|
||||
// or a comma-separated list of trusted proxy IPs/CIDRs.
|
||||
if (process.env.TRUST_PROXY) {
|
||||
const raw = process.env.TRUST_PROXY.trim();
|
||||
let trustProxyValue;
|
||||
if (raw === 'true') trustProxyValue = true;
|
||||
else if (raw === 'false') trustProxyValue = false;
|
||||
else if (/^\d+$/.test(raw)) trustProxyValue = parseInt(raw, 10);
|
||||
else if (raw.includes(',')) trustProxyValue = raw.split(',').map((s) => s.trim());
|
||||
else trustProxyValue = raw;
|
||||
app.set('trust proxy', trustProxyValue);
|
||||
console.log(`[startup] trust proxy set to: ${JSON.stringify(trustProxyValue)}`);
|
||||
}
|
||||
|
||||
// CORS — allow only the configured frontend origin
|
||||
const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000')
|
||||
.split(',')
|
||||
@@ -70,6 +103,10 @@ const globalLimiter = rateLimit({
|
||||
});
|
||||
app.use(globalLimiter);
|
||||
|
||||
// Impersonation is strictly read-only: refuse every write made with an impersonation
|
||||
// token before it can reach any route (see utils/impersonation.js).
|
||||
app.use(require('./utils/impersonation').readOnlyGuard);
|
||||
|
||||
// Raw body parsing middleware for webhooks
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.startsWith('/api/webhooks')) {
|
||||
@@ -110,6 +147,9 @@ const setupRoutes = require('./routes/setupRoutes');
|
||||
const costRoutes = require('./routes/costRoutes');
|
||||
const cashupRoutes = require('./routes/cashupRoutes');
|
||||
const statsRoutes = require('./routes/statsRoutes');
|
||||
const adminRoutes = require('./routes/adminRoutes');
|
||||
const backupRoutes = require('./routes/backupRoutes');
|
||||
const yocoRoutes = require('./routes/yocoRoutes');
|
||||
|
||||
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
|
||||
app.use('/api/webhooks', webhookRoutes);
|
||||
@@ -139,11 +179,31 @@ app.use('/api/setup', setupRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
app.use('/api', costRoutes);
|
||||
app.use('/api/cashups', cashupRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/coupons', require('./routes/couponRoutes'));
|
||||
app.use('/api/consent', require('./routes/consentRoutes'));
|
||||
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
|
||||
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
|
||||
app.use('/api/allocations', require('./routes/allocationRoutes'));
|
||||
app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
|
||||
app.use('/api/feedback', require('./routes/feedbackRoutes'));
|
||||
app.use('/api/analytics', require('./routes/analyticsRoutes'));
|
||||
app.use('/api/backups', backupRoutes);
|
||||
app.use('/api/yoco', yocoRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
const { getSettingSync, warmCache } = require('./utils/settingsCache');
|
||||
warmCache().catch(() => {});
|
||||
app.use('/uploads', express.static('public/uploads'));
|
||||
// Uploaded branding assets can include SVGs, which may embed <script>/event
|
||||
// handlers. Serving them inline lets a compromised/malicious upload run script
|
||||
// in the site's origin if opened directly, so pin the safe response headers
|
||||
// (no inline execution, no MIME-sniffing to HTML/script) on every asset here.
|
||||
app.use('/uploads', express.static('public/uploads', {
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Shared page helpers ────────────────────────────────────────────────────────
|
||||
const jwt = require('jsonwebtoken');
|
||||
@@ -313,7 +373,7 @@ app.get('/docs', async (req, res) => {
|
||||
|
||||
let user;
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true },
|
||||
@@ -334,6 +394,7 @@ app.get('/docs', async (req, res) => {
|
||||
// ── Endpoint data ────────────────────────────────────────────────────────
|
||||
const GROUPS = [
|
||||
{ title: 'Users', base: '/api/users', endpoints: [
|
||||
{ method:'GET', path:'/api/users/activity', auth:'Bearer', desc:'Recent account activity (logins, password changes) for the current user' },
|
||||
{ method:'POST', path:'/api/users', auth:'public', desc:'Register a new account',
|
||||
request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }},
|
||||
responses:[
|
||||
@@ -345,8 +406,24 @@ app.get('/docs', async (req, res) => {
|
||||
request:{ body:{ email:'jane@example.com', password:'secret123' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }},
|
||||
{ status:200, desc:'Two-factor code sent (account has 2FA on) — no token yet', body:{ twoFactorRequired:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:200, desc:'Admin/supervisor without 2FA — code sent; verifying it turns 2FA on', body:{ twoFactorRequired:true, enrolling:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/login/2fa', auth:'public', desc:'Second login step — exchange the emailed/WhatsApp code for a JWT',
|
||||
notes:'Codes expire after 10 minutes and allow 5 attempts. With rememberDevice, the response includes a deviceToken; send it with later logins to skip the code for 30 days.',
|
||||
request:{ body:{ challengeId:'c0ffee00-...', code:'123456', rememberDevice:true }},
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...', deviceToken:'9f2c...' }},
|
||||
{ status:400, desc:'Wrong or expired code', body:{ message:'Incorrect code. 4 attempts left.' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/login/2fa/resend', auth:'public', desc:'Send a new code for a pending login challenge',
|
||||
notes:'At most one send per 60 seconds.',
|
||||
request:{ body:{ challengeId:'c0ffee00-...' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:429, desc:'Too soon', body:{ message:'Please wait 42 seconds before requesting another code.' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/forgot', auth:'public', desc:'Request a password reset email',
|
||||
request:{ body:{ email:'jane@example.com' }},
|
||||
responses:[
|
||||
@@ -378,6 +455,29 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Success', body:{ message:'All sessions revoked.' }}]},
|
||||
{ method:'POST', path:'/api/users/close-account', auth:'user+', desc:'Deactivate own account',
|
||||
responses:[{ status:200, desc:'Success', body:{ message:'Account deactivated.' }}]},
|
||||
{ method:'POST', path:'/api/users/2fa/enable', auth:'user+', desc:'Start turning on two-factor authentication — sends a code to the chosen channel',
|
||||
notes:'WhatsApp needs a valid phone number on the account and a connected WhatsApp session.',
|
||||
request:{ body:{ method:'email', password:'secret123' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Code sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:400, desc:'Wrong password or unavailable channel', body:{ message:'Incorrect password' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/2fa/confirm', auth:'user+', desc:'Finish turning on two-factor authentication with the code that was sent',
|
||||
request:{ body:{ challengeId:'c0ffee00-...', code:'123456' }},
|
||||
responses:[{ status:200, desc:'Enabled', body:{ twoFactorEnabled:true, twoFactorMethod:'email' }}]},
|
||||
{ method:'POST', path:'/api/users/2fa/disable', auth:'user+', desc:'Turn off two-factor authentication',
|
||||
request:{ body:{ password:'secret123' }},
|
||||
notes:'Admins and supervisors must keep two-factor on — 403 for them.',
|
||||
responses:[
|
||||
{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }},
|
||||
{ status:403, desc:'Required for this role', body:{ message:"Two-factor authentication is required for your role and can't be turned off." }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/verify-password', auth:'user+', desc:'Re-confirm own password without starting a new session',
|
||||
request:{ body:{ password:'secret123' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Correct', body:{ ok:true }},
|
||||
{ status:401, desc:'Wrong password', body:{ message:'Incorrect password' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/users', auth:'supervisor+', desc:'List all users (paginated). All filters are applied server-side.',
|
||||
queryParams:{ page:'Page number (default 1)', limit:'Items per page (max 200, default 100)', search:'Search by name, email, or phone number (case-insensitive substring)', role:'Filter by role: admin|supervisor|staff|user', isActive:'Filter by active status: true|false (omit for both)' },
|
||||
responses:[
|
||||
@@ -418,6 +518,14 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Events', base: '/api/events', endpoints: [
|
||||
{ method:'GET', path:'/api/events/admin/all', auth:'admin', desc:'All events, including inactive ones' },
|
||||
{ method:'GET', path:'/api/events/:id/ics', auth:'Public (optional login)', desc:'Download an .ics calendar file for an event' },
|
||||
{ method:'GET', path:'/api/events/:id/attachments', auth:'Public', desc:'List the downloadable attachments of an event' },
|
||||
{ method:'POST', path:'/api/events/:id/attachments', auth:'supervisor+', desc:'Upload an attachment (multipart, field "file")' },
|
||||
{ method:'DELETE', path:'/api/events/:eventId/attachments/:attachmentId', auth:'supervisor+', desc:'Delete an attachment' },
|
||||
{ method:'GET', path:'/api/events/attachments/status', auth:'admin', desc:'Diagnostics for the attachments pipeline' },
|
||||
{ method:'POST', path:'/api/events/attachments/sync', auth:'admin', desc:'Re-sync attachment records with the files on disk' },
|
||||
{ method:'POST', path:'/api/events/options/:id/variants', auth:'supervisor+', desc:'Add a variant (e.g. a size) to an event option' },
|
||||
{ method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.',
|
||||
queryParams:{ limit:'Max results (default 20)' },
|
||||
responses:[
|
||||
@@ -495,6 +603,12 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Registrations', base: '/api/registrations', endpoints: [
|
||||
{ method:'PUT', path:'/api/registrations/:id/options', auth:'Bearer (owner or staff)', desc:'Change the items on a registration (add/remove), within stock and paid-ticket limits' },
|
||||
{ method:'POST', path:'/api/registrations/:id/coupon', auth:'Bearer (owner or staff)', desc:'Apply a coupon code to an existing registration', request:{ body:{ code:'STAFF50' }}},
|
||||
{ method:'DELETE', path:'/api/registrations/:id/coupon', auth:'staff+', desc:'Remove the coupon from a registration (frees the use)' },
|
||||
{ method:'GET', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Saved draft of the attendee forms' },
|
||||
{ method:'PUT', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Save a draft of the attendee forms (not submitted)' },
|
||||
{ method:'PUT', path:'/api/registrations/:id/forms/responses', auth:'supervisor+', desc:'Replace (edit) the submitted form responses of a registration' },
|
||||
{ method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.',
|
||||
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
|
||||
responses:[
|
||||
@@ -524,6 +638,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' }}]},
|
||||
@@ -542,7 +660,120 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
]},
|
||||
|
||||
{ title: 'Impersonation', base: '/api/admin', endpoints: [
|
||||
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
|
||||
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
|
||||
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
|
||||
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
|
||||
{ method:'GET', path:'/api/admin/impersonate/current', auth:'impersonation token', desc:'The live impersonation session' },
|
||||
{ method:'GET', path:'/api/admin/impersonation-sessions', auth:'admin', desc:'Impersonation sessions with their request trail' },
|
||||
]},
|
||||
{ title: 'Registration funnel', base: '/api/analytics', endpoints: [
|
||||
{ method:'POST', path:'/api/analytics/track', auth:'Public (rate-limited)', desc:'Record a funnel step from a cookie-consented browser (always 204)',
|
||||
request:{ body:{ sessionId:'random-uuid', eventId:'ev-uuid-...', stage:'view', source:'camp-2025' }}},
|
||||
{ method:'GET', path:'/api/analytics/funnel', auth:'supervisor+', desc:'Funnel for ?eventIds=a,b&from=&to= — totals, conversion, daily trend, by source/device/option' },
|
||||
]},
|
||||
{ title: 'Post-event feedback', base: '/api/feedback', endpoints: [
|
||||
{ method:'GET', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'An event\'s feedback form, with invite and response counts' },
|
||||
{ method:'PUT', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Create/update the feedback form (questions lock once answered)',
|
||||
request:{ body:{ sendAt:'2025-07-13T09:00:00.000Z', audience:'checked_in', anonymous:false, intro:'Thanks for coming!', fields:[{ type:'rating', label:'Overall?', isRequired:true }, { type:'multiple_choice', label:'Best part', options:['Worship','Talks'] }] }}},
|
||||
{ method:'DELETE', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Remove the feedback form' },
|
||||
{ method:'POST', path:'/api/feedback/event/:eventId/send-now', auth:'supervisor+', desc:'Send the feedback form now' },
|
||||
{ method:'GET', path:'/api/feedback/public/:token', auth:'Link holder', desc:'The form behind a personal feedback link' },
|
||||
{ method:'POST', path:'/api/feedback/public/:token', auth:'Link holder', desc:'Submit feedback once', request:{ body:{ answers:{ 'field-uuid':'5' } }}},
|
||||
{ method:'GET', path:'/api/feedback/summary', auth:'supervisor+', desc:'Feedback summary for ?eventIds=a,b (response rate, averages, breakdowns, comments)' },
|
||||
]},
|
||||
{ title: 'Sponsor a spot', base: '/api/sponsorships', endpoints: [
|
||||
{ method:'GET', path:'/api/sponsorships/event/:eventId', auth:'Public', desc:'Sponsorship progress for an event (spots funded, goal) — never who was helped',
|
||||
responses:[{ status:200, desc:'Progress', body:{ enabled:true, open:true, spotPrice:450, showProgress:true, goalSpots:20, spotsFunded:12, sponsors:9 }}]},
|
||||
{ method:'POST', path:'/api/sponsorships/checkout', auth:'Public (optional login)', desc:'Start a Yoco checkout to sponsor spots or a custom amount; the money lands in the event sponsorship pool',
|
||||
request:{ body:{ eventId:'ev-uuid-...', spots:2, anonymous:false, sponsorName:'The Smith family', message:'Have a great camp!', name:'Sam Smith', email:'sam@example.com' }},
|
||||
responses:[{ status:200, desc:'Checkout created', body:{ redirectUrl:'https://pay.yoco.com/...', amount:900 }}]},
|
||||
{ method:'GET', path:'/api/sponsorships/event/:eventId/pool', auth:'supervisor+', desc:'Sponsorship pool totals: received, assigned, remaining' },
|
||||
]},
|
||||
{ title: 'Rooms & groups', base: '/api/allocations', endpoints: [
|
||||
{ method:'GET', path:'/api/allocations/event/:eventId', auth:'supervisor+', desc:'Allocation board: sets with groups and assignments, named attendees, possible leaders' },
|
||||
{ method:'POST', path:'/api/allocations/sets', auth:'supervisor+', desc:'Create an allocation set', request:{ body:{ eventId:'ev-uuid-...', name:'Rooms', showOnTicket:true }}},
|
||||
{ method:'PUT', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Rename a set or show it on tickets' },
|
||||
{ method:'DELETE', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Delete a set and its groups' },
|
||||
{ method:'POST', path:'/api/allocations/sets/:id/groups', auth:'supervisor+', desc:'Add a group', request:{ body:{ name:'Cabin 1', capacity:8, gender:'female', leaderUserId:'user-uuid-...' }}},
|
||||
{ method:'PUT', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Edit a group' },
|
||||
{ method:'DELETE', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Delete a group (its people become unassigned)' },
|
||||
{ method:'PUT', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Place (or with groupId null, unplace) an attendee; enforces capacity and gender', request:{ body:{ attendeeId:'att-uuid-...', groupId:'grp-uuid-...' }}},
|
||||
{ method:'DELETE', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Clear every assignment in a set' },
|
||||
{ method:'POST', path:'/api/allocations/sets/:id/auto', auth:'supervisor+', desc:'Auto-allocate everyone not yet placed', request:{ body:{ mode:'together' }}, responses:[{ status:200, desc:'Done', body:{ placed:12, unplaced:1 }}]},
|
||||
{ method:'GET', path:'/api/allocations/sets/:id/lists', auth:'supervisor+', desc:'Per-group lists for printing (?medical=true adds medical details; audited)' },
|
||||
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
|
||||
]},
|
||||
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
|
||||
{ method:'DELETE', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Withdraw a transfer offer that has not been accepted yet' },
|
||||
{ method:'GET', path:'/api/tickets/transfer-offer/:token', auth:'Link holder', desc:'The ticket offered behind a transfer link (status, event, ticket type, sender first name)' },
|
||||
{ method:'POST', path:'/api/tickets/transfer-offer/:token/accept', auth:'Link holder', desc:'Accept the ticket — the transfer happens now (new QR code, ticket sent to the new holder)' },
|
||||
{ method:'POST', path:'/api/tickets/transfer-offer/:token/decline', auth:'Link holder', desc:'Decline the ticket offer' },
|
||||
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
|
||||
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
|
||||
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
|
||||
{ method:'GET', path:'/api/tickets/:id/transfers', auth:'payer, holder or staff+', desc:'Transfer history of a ticket' },
|
||||
]},
|
||||
{ title: 'Offline scanning', base: '/api/scan-locks', endpoints: [
|
||||
{ method:'GET', path:'/api/scan-locks', auth:'staff+', desc:'Active offline locks for an event (?eventId=)' },
|
||||
{ method:'POST', path:'/api/scan-locks', auth:'staff+', desc:'Lock a section (or the whole event) to this device for offline scanning',
|
||||
request:{ body:{ eventId:'ev-uuid-...', sectionId:'sec-uuid-...', deviceId:'device-uuid', deviceName:'Chrome on Android' }},
|
||||
responses:[{ status:201, desc:'Locked', body:{ id:'lock-uuid-...', sectionName:'Main hall' }}, { status:409, desc:'Held elsewhere', body:{ message:'"Main hall" is offline-locked by Ann on Chrome on Android.' }}]},
|
||||
{ method:'GET', path:'/api/scan-locks/:id/snapshot', auth:'staff+', desc:'Tickets valid in the lock scope, for the device to keep offline (lock holder, ?deviceId=)' },
|
||||
{ method:'POST', path:'/api/scan-locks/:id/sync', auth:'staff+', desc:'Upload offline scans (idempotent by clientScanId; capped at what is left on each ticket)',
|
||||
request:{ body:{ deviceId:'device-uuid', scans:[{ clientScanId:'scan-uuid', qrCode:'qr-uuid', qty:1, scannedAt:'2025-07-10T08:15:00.000Z' }] }},
|
||||
responses:[{ status:200, desc:'Synced', body:{ accepted:1, duplicates:0, conflicts:[] }}]},
|
||||
{ method:'DELETE', path:'/api/scan-locks/:id', auth:'staff+', desc:'End offline mode (lock holder)' },
|
||||
{ method:'POST', path:'/api/scan-locks/:id/force-release', auth:'supervisor+', desc:'Release a lost device\'s lock' },
|
||||
{ method:'GET', path:'/api/scan-locks/reports', auth:'supervisor+', desc:'Offline sync reports for an event' },
|
||||
{ method:'POST', path:'/api/scan-locks/reports/:id/review', auth:'supervisor+', desc:'Mark a sync report reviewed' },
|
||||
]},
|
||||
{ title: 'Waitlist', base: '/api/waitlist', endpoints: [
|
||||
{ method:'POST', path:'/api/waitlist', auth:'public', desc:'Join the waitlist for a sold-out line (login needed when the event requires it)',
|
||||
request:{ body:{ eventId:'ev-uuid-...', eventOptionId:'opt-uuid-...', quantity:2 }},
|
||||
responses:[{ status:201, desc:'Joined', body:{ id:'wl-uuid-...', status:'waiting', ahead:3 }}, { status:400, desc:'Places available', body:{ message:'There are places available — you can register now.' }}]},
|
||||
{ method:'GET', path:'/api/waitlist/mine', auth:'user+', desc:'My active waitlist entries' },
|
||||
{ method:'DELETE', path:'/api/waitlist/:id', auth:'user+', desc:'Leave the waitlist' },
|
||||
{ method:'GET', path:'/api/waitlist/claim/:token', auth:'public', desc:'What a claim link offers (rate-limited)' },
|
||||
{ method:'GET', path:'/api/waitlist/events/:eventId', auth:'staff+', desc:'The queue for an event' },
|
||||
{ method:'POST', path:'/api/waitlist/:id/offer', auth:'supervisor+', desc:'Offer places now, even over the stock limit' },
|
||||
{ method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} },
|
||||
{ method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' },
|
||||
]},
|
||||
{ title: 'Parental consent', base: '/api/consent', endpoints: [
|
||||
{ method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)',
|
||||
responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]},
|
||||
{ method:'POST', path:'/api/consent/:token', auth:'public', desc:'Give or decline consent. Records name, time, IP, device and the consent text version; medical/emergency details are stored encrypted.',
|
||||
request:{ body:{ decision:'give', signedName:'Pat Smith', isGuardian:true, medical:{ allergies:'Peanuts' }, emergency:{ name:'Gran', phone:'0821234567' }, photoConsent:true }},
|
||||
responses:[{ status:200, desc:'Answered', body:{ status:'given' }}, { status:409, desc:'Already answered', body:{ message:'Consent has already been given.' }}]},
|
||||
{ method:'GET', path:'/api/consent/events/:eventId', auth:'staff+', desc:'Consent status for every attendee on an event' },
|
||||
{ method:'POST', path:'/api/consent/requests/:id/resend', auth:'user+', desc:'Re-send a pending request with a fresh link (registrant or staff)' },
|
||||
{ method:'POST', path:'/api/consent/events/:eventId/resend-pending', auth:'supervisor+', desc:'Re-send every pending request on an event' },
|
||||
{ method:'POST', path:'/api/consent/events/:eventId/rerequest', auth:'supervisor+', desc:'Ask again where consent was given on an older version of the text, or never requested' },
|
||||
{ method:'GET', path:'/api/consent/events/:eventId/medical', auth:'supervisor+', desc:'Decrypted medical & emergency list; every view is written to the admin audit log' },
|
||||
]},
|
||||
{ title: 'Coupons', base: '/api/coupons', endpoints: [
|
||||
{ method:'POST', path:'/api/coupons/validate', auth:'public', desc:'Check a code against a basket before registering; returns the discount. Login needed for restricted codes; staff can pass forUserId (or null for a walk-in).',
|
||||
request:{ body:{ code:'STAFF50', eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
|
||||
responses:[
|
||||
{ status:200, desc:'Valid', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, subtotal:900, total:450 }},
|
||||
{ status:400, desc:'Not usable', body:{ valid:false, message:'This code has expired.' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/coupons', auth:'supervisor+', desc:'List codes with use counts and total discount given',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'cpn-uuid-...', code:'STAFF50', type:'percent', value:50, summary:'50% off', active:true, uses:3, totalDiscount:1350 }]}]},
|
||||
{ method:'POST', path:'/api/coupons', auth:'supervisor+', desc:'Create a code. type: percent | fixed | comp. eventIds/options/userIds/restrictRoles/restrictDomain are optional.',
|
||||
request:{ body:{ code:'STAFF50', type:'percent', value:50, eventIds:['ev-uuid-...'], restrictRoles:['staff','supervisor','admin'], maxUsesPerUser:1, stackEarlyBird:false }},
|
||||
responses:[{ status:201, desc:'Created', body:{ id:'cpn-uuid-...', code:'STAFF50' }}, { status:409, desc:'Code exists', body:{ message:'The code STAFF50 already exists' }}]},
|
||||
{ method:'GET', path:'/api/coupons/:id', auth:'supervisor+', desc:'One code with every use (redemption)' },
|
||||
{ method:'PUT', path:'/api/coupons/:id', auth:'supervisor+', desc:'Edit a code or turn it off ({ active:false }). Discounts already given keep their amount; a used code can\'t be renamed.' },
|
||||
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
|
||||
]},
|
||||
{ title: 'Payments', base: '/api/payments', endpoints: [
|
||||
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
|
||||
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
|
||||
{ method:'POST', path:'/api/payments/yoco-checkout/send', auth:'supervisor+', desc:'Send a generated Yoco payment link to the registrant by email or WhatsApp', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
|
||||
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
|
||||
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
|
||||
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
|
||||
responses:[
|
||||
@@ -582,6 +813,10 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Tickets', base: '/api/tickets', endpoints: [
|
||||
{ method:'GET', path:'/api/tickets', auth:'supervisor+', desc:'All tickets' },
|
||||
{ method:'GET', path:'/api/tickets/:id', auth:'Bearer (owner or staff)', desc:'A ticket by id' },
|
||||
{ method:'GET', path:'/api/tickets/qr/:qrCode', auth:'staff+', desc:'A ticket by QR code' },
|
||||
{ method:'PUT', path:'/api/tickets/email-sent', auth:'admin', desc:'Mark tickets as emailed' },
|
||||
{ method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]},
|
||||
{ method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)',
|
||||
@@ -661,6 +896,8 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Reports', base: '/api/reports', endpoints: [
|
||||
{ method:'POST', path:'/api/reports/excel', auth:'Bearer', desc:'Build an Excel file from a report payload' },
|
||||
{ method:'POST', path:'/api/reports/whatsapp', auth:'Bearer', desc:'Send a report PDF by WhatsApp' },
|
||||
{ method:'POST', path:'/api/reports/pdf', auth:'user+', desc:'Generate a PDF report of own registrations and tickets and return it as a binary download',
|
||||
responses:[{ status:200, desc:'PDF file (application/pdf)', body:'<binary PDF>' }]},
|
||||
{ method:'POST', path:'/api/reports/email', auth:'user+', desc:'Generate PDF report and email it to the authenticated user',
|
||||
@@ -685,6 +922,8 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Cashups', base: '/api/cashups', endpoints: [
|
||||
{ method:'GET', path:'/api/cashups/event/:eventId/cash-by-user', auth:'supervisor+', desc:'Payments recorded for an event, by staff member and method' },
|
||||
{ method:'PUT', path:'/api/cashups/event/:eventId/person-cash/:userId', auth:'supervisor+', desc:'Enter one staff member\'s physical cash count for an event' },
|
||||
{ method:'GET', path:'/api/cashups/event/:eventId', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history',
|
||||
pathParams:{ ':eventId':'Event UUID' },
|
||||
responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]},
|
||||
@@ -706,6 +945,8 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Uploads', base: '/api/uploads', endpoints: [
|
||||
{ method:'POST', path:'/api/uploads/logo', auth:'admin (open during first-run setup)', desc:'Upload the site logo (multipart, field "image")' },
|
||||
{ method:'POST', path:'/api/uploads/favicon', auth:'admin (open during first-run setup)', desc:'Upload the site favicon (multipart, field "image")' },
|
||||
{ method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.',
|
||||
notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.',
|
||||
responses:[
|
||||
@@ -762,6 +1003,10 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
|
||||
{ method:'POST', path:'/api/whatsapp/start', auth:'admin', desc:'Start (or restart) the WAWP WhatsApp instance' },
|
||||
{ method:'POST', path:'/api/whatsapp/request-code', auth:'admin', desc:'Request a pairing code to link a WhatsApp number' },
|
||||
{ method:'POST', path:'/api/whatsapp/delete-instance', auth:'admin', desc:'Delete the WAWP instance' },
|
||||
{ method:'POST', path:'/api/whatsapp/webhook', auth:'—', desc:'WAWP event webhook (alias of /api/webhooks/whatsapp)' },
|
||||
{ method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)',
|
||||
responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]},
|
||||
{ method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.',
|
||||
@@ -782,6 +1027,17 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Logged out', body:{ message:'Logged out.' }}]},
|
||||
]},
|
||||
|
||||
{ title: 'Dashboard stats', base: '/api/stats', endpoints: [
|
||||
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
|
||||
]},
|
||||
{ title: 'Backups', base: '/api/backups', endpoints: [
|
||||
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
|
||||
{ method:'POST', path:'/api/backups/run', auth:'admin', desc:'Run a backup now' },
|
||||
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
|
||||
]},
|
||||
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
|
||||
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
|
||||
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
|
||||
@@ -791,6 +1047,21 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
|
||||
]},
|
||||
|
||||
{ title: 'Yoco', base: '/api/yoco', endpoints: [
|
||||
{ method:'GET', path:'/api/yoco/config', auth:'admin', desc:'Yoco settings with secrets masked (prefix + last 3 characters), where each comes from (db, env or none), test/live mode and the default webhook URL.',
|
||||
responses:[{ status:200, desc:'Success', body:{ secretKeyMasked:'sk_test_***************aba', secretKeySource:'db', mode:'test', webhookSecretMasked:'whsec_*****************1Xz', webhookSecretSource:'db', webhookId:'sub_123', webhookUrl:'https://api.example.com/api/webhooks/yoco', configured:true }}]},
|
||||
{ method:'PUT', path:'/api/yoco/config', auth:'admin', desc:'Save the secret key and/or webhook secret (stored AES-256-GCM encrypted). A new secret key is checked against Yoco first. An empty string clears the value (the .env fallback then applies).',
|
||||
request:{ body:{ secretKey:'sk_test_...', webhookSecret:'whsec_...' }},
|
||||
responses:[{ status:200, desc:'Saved', body:{ message:'Yoco settings saved' }}]},
|
||||
{ method:'GET', path:'/api/yoco/webhooks', auth:'admin', desc:'Webhooks registered on the Yoco account, flagged isActive (its secret is the stored one) and pointsHere (URL is this server).',
|
||||
responses:[{ status:200, desc:'Success', body:{ webhooks:[{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test', isActive:true, pointsHere:true }], defaultUrl:'https://api.example.com/api/webhooks/yoco' }}]},
|
||||
{ method:'POST', path:'/api/yoco/webhooks', auth:'admin', desc:'Register a webhook at Yoco (defaults: name "hope-events", URL = BACKEND_URL/api/webhooks/yoco) and store the secret Yoco returns. The URL must be public https.',
|
||||
request:{ body:{ name:'hope-events', url:'https://api.example.com/api/webhooks/yoco' }},
|
||||
responses:[{ status:201, desc:'Registered', body:{ message:'Webhook registered and its secret saved', webhook:{ id:'sub_123', name:'hope-events', url:'https://api.example.com/api/webhooks/yoco', mode:'test' }, webhookSecretMasked:'whsec_*****************1Xz' }}]},
|
||||
{ method:'DELETE', path:'/api/yoco/webhooks/:id', auth:'admin', desc:'Delete a webhook at Yoco. If it was the active one, the stored webhook secret is cleared too.',
|
||||
responses:[{ status:200, desc:'Deleted', body:{ message:'Webhook deleted', clearedSecret:false }}]},
|
||||
]},
|
||||
|
||||
{ title: 'Settings', base: '/api/settings', endpoints: [
|
||||
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
|
||||
responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.png' }}]},
|
||||
@@ -1118,6 +1389,14 @@ function toggle(id) {
|
||||
|
||||
// Error middleware
|
||||
app.use(notFound);
|
||||
|
||||
// Sentry captures the error here, then passes it through unchanged — errorHandler
|
||||
// below remains the sole source of what's actually sent back to the client.
|
||||
if (process.env.SENTRY_DSN) {
|
||||
const Sentry = require('@sentry/node');
|
||||
Sentry.setupExpressErrorHandler(app);
|
||||
}
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
// Start server
|
||||
@@ -1181,6 +1460,105 @@ 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);
|
||||
}
|
||||
|
||||
// Funnel analytics — once a day, roll raw rows older than 90 days into daily totals.
|
||||
try {
|
||||
const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { rollupOldEvents } = require('./utils/analytics');
|
||||
const run = async () => {
|
||||
try {
|
||||
const n = await rollupOldEvents();
|
||||
if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`);
|
||||
} catch (e) {
|
||||
console.warn('[analytics] Rollup failed:', e?.message || e);
|
||||
}
|
||||
};
|
||||
setTimeout(run, 5 * 60 * 1000);
|
||||
setInterval(run, 24 * 60 * 60 * 1000);
|
||||
console.log('[analytics] Rollup daily.');
|
||||
} else {
|
||||
console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[analytics] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Feedback job — send post-event feedback forms at their send time, and the one
|
||||
// reminder 3 days later.
|
||||
try {
|
||||
const enabled = String(process.env.FEEDBACK_JOB_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runFeedbackJob } = require('./utils/feedback');
|
||||
const everyMs = parseInt(process.env.FEEDBACK_JOB_INTERVAL_MS || String(15 * 60 * 1000), 10);
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await runFeedbackJob();
|
||||
if (r.sent || r.reminded) console.log(`[feedback] ${r.sent} invite(s), ${r.reminded} reminder(s) sent.`);
|
||||
} catch (e) {
|
||||
console.warn('[feedback] Job failed:', e?.message || e);
|
||||
}
|
||||
}, everyMs);
|
||||
console.log(`[feedback] Job every ${Math.round(everyMs / 60000)} min.`);
|
||||
} else {
|
||||
console.log('[feedback] Job disabled by env FEEDBACK_JOB_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[feedback] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily parental-consent job — reminders for unanswered requests, and purging
|
||||
// medical/emergency details a set number of days after each event.
|
||||
try {
|
||||
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');
|
||||
@@ -1208,6 +1586,36 @@ app.listen(PORT, () => {
|
||||
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Nightly database backup at 02:00 local time (before the 03:00 temp cleanup)
|
||||
try {
|
||||
const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runBackup } = require('./utils/backupUtils');
|
||||
function scheduleNightlyBackup() {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(2, 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const result = await runBackup();
|
||||
console.log(`[backup] Completed: ${result.filename}`);
|
||||
} catch (e) {
|
||||
console.error('[backup] Failed:', e?.message || e);
|
||||
} finally {
|
||||
scheduleNightlyBackup();
|
||||
}
|
||||
}, next.getTime() - now.getTime());
|
||||
}
|
||||
scheduleNightlyBackup();
|
||||
console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.');
|
||||
} else {
|
||||
console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[backup] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Scheduled emails worker (polling)
|
||||
try {
|
||||
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
@@ -1271,6 +1679,9 @@ app.listen(PORT, () => {
|
||||
process.on('unhandledRejection', (err) => {
|
||||
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
|
||||
console.error(err?.stack || err);
|
||||
if (process.env.SENTRY_DSN) {
|
||||
try { require('@sentry/node').captureException(err); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = { app, prisma };
|
||||
@@ -2,6 +2,10 @@ const rateLimit = require("express-rate-limit");
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const prisma = require('../config/db');
|
||||
const { mustEnrolTwoFactor } = require('../utils/twoFactor');
|
||||
|
||||
// The setup wizard's token may finish setup without 2FA for this long.
|
||||
const SETUP_TOKEN_GRACE_SECONDS = 2 * 60 * 60;
|
||||
|
||||
// Protect routes - verify token
|
||||
const protect = async (req, res, next) => {
|
||||
@@ -13,8 +17,9 @@ const protect = async (req, res, next) => {
|
||||
// Get token from header
|
||||
token = req.headers.authorization.split(' ')[1];
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
// Verify token — pin the algorithm so a token signed with an
|
||||
// unexpected/attacker-chosen algorithm is never accepted.
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
|
||||
// Get user from the token (exclude password)
|
||||
req.user = await prisma.user.findUnique({
|
||||
@@ -28,7 +33,8 @@ const protect = async (req, res, next) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
phoneNumber: true,
|
||||
tokenVersion: true
|
||||
tokenVersion: true,
|
||||
twoFactorEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,6 +48,17 @@ const protect = async (req, res, next) => {
|
||||
return next(new Error('User account is deactivated'));
|
||||
}
|
||||
|
||||
// An admin viewing as this user: only while the session is live, and read-only.
|
||||
if (decoded.imp) {
|
||||
const { liveSession } = require('../utils/impersonation');
|
||||
const session = await liveSession(String(decoded.imp));
|
||||
if (!session || session.targetUserId !== req.user.id) {
|
||||
return res.status(401).json({ message: 'The impersonation session has ended.', code: 'IMPERSONATION_ENDED' });
|
||||
}
|
||||
req.impersonation = session;
|
||||
req.readOnly = true;
|
||||
}
|
||||
|
||||
// Revocation check — tokenVersion in JWT must match DB
|
||||
// Old tokens without tokenVersion are treated as version 0
|
||||
const tokenVer = decoded.tokenVersion ?? 0;
|
||||
@@ -50,6 +67,18 @@ const protect = async (req, res, next) => {
|
||||
return next(new Error('Session has been revoked. Please log in again.'));
|
||||
}
|
||||
|
||||
// Admins and supervisors must use 2FA. A session that predates the
|
||||
// requirement (or the user's promotion) is sent back to log in, where they
|
||||
// enrol. The setup wizard's own token gets a short grace period.
|
||||
const inSetupGrace = decoded.setup === true && Date.now() / 1000 - (decoded.iat || 0) < SETUP_TOKEN_GRACE_SECONDS;
|
||||
// (An impersonating admin has already passed 2FA themselves.)
|
||||
if (!inSetupGrace && !req.impersonation && (await mustEnrolTwoFactor(req.user))) {
|
||||
return res.status(401).json({
|
||||
message: 'Two-factor authentication is required for your role. Please log in again to set it up.',
|
||||
code: 'TWO_FACTOR_REQUIRED',
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -105,13 +134,23 @@ const optionalAuth = async (req, res, next) => {
|
||||
}
|
||||
try {
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
|
||||
});
|
||||
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
|
||||
req.user = user;
|
||||
if (decoded.imp) {
|
||||
const { liveSession } = require('../utils/impersonation');
|
||||
const session = await liveSession(String(decoded.imp));
|
||||
req.readOnly = true;
|
||||
if (session && session.targetUserId === user.id) {
|
||||
req.user = user;
|
||||
req.impersonation = session;
|
||||
}
|
||||
} else {
|
||||
req.user = user;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Token invalid or expired — proceed without user
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listAuditLog } = require('../controllers/adminAuditController');
|
||||
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/audit-log', protect, admin, listAuditLog);
|
||||
router.get('/impersonation-sessions', protect, admin, listSessions);
|
||||
// Called with the impersonation token itself (which belongs to the viewed user).
|
||||
router.post('/impersonate/end', protect, endImpersonation);
|
||||
router.post('/impersonate/log', protect, logPageView);
|
||||
router.get('/impersonate/current', protect, currentImpersonation);
|
||||
router.post('/impersonate/:userId', protect, admin, startImpersonation);
|
||||
|
||||
module.exports = router;
|
||||
@@ -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,18 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { trackStage, getFunnel } = require('../controllers/analyticsController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
const trackLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: (req, res) => res.status(204).end(), // silently drop floods
|
||||
});
|
||||
|
||||
router.post('/track', trackLimiter, trackStage);
|
||||
router.get('/funnel', protect, supervisor, getFunnel);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBackups, triggerBackup, downloadBackup } = require('../controllers/backupController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', protect, admin, getBackups);
|
||||
router.post('/run', protect, admin, triggerBackup);
|
||||
router.get('/:filename/download', protect, admin, downloadBackup);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,20 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
// The token in the link is the parent's only credential, so keep guessing slow.
|
||||
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.get('/events/:eventId', protect, staff, listEventConsent);
|
||||
router.post('/events/:eventId/resend-pending', protect, supervisor, resendPending);
|
||||
router.post('/events/:eventId/rerequest', protect, supervisor, rerequestConsent);
|
||||
router.get('/events/:eventId/medical', protect, supervisor, medicalList);
|
||||
router.post('/requests/:id/resend', protect, resendRequest);
|
||||
|
||||
// Public consent page
|
||||
router.get('/:token', consentLimiter, getConsent);
|
||||
router.post('/:token', consentLimiter, answerConsent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// Check a code against a basket before registering (guests included)
|
||||
router.post('/validate', optionalAuth, validateCode);
|
||||
|
||||
router.get('/', protect, supervisor, listCoupons);
|
||||
router.post('/', protect, supervisor, createCoupon);
|
||||
router.get('/redemptions', protect, supervisor, listRedemptions);
|
||||
router.get('/:id', protect, supervisor, getCoupon);
|
||||
router.put('/:id', protect, supervisor, updateCoupon);
|
||||
|
||||
module.exports = router;
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
getEventById,
|
||||
getEventIcs,
|
||||
updateEvent,
|
||||
getEventNotifyRecipients,
|
||||
updateEventNotifyRecipients,
|
||||
@@ -44,6 +45,7 @@ router.post('/attachments/sync', protect, admin, attachmentsSync);
|
||||
// optionalAuth populates req.user when a valid token is present so staff/supervisor/admin
|
||||
// can still load inactive events (e.g. for cashup or editing) without being 404'd.
|
||||
router.get('/:id', optionalAuth, getEventById);
|
||||
router.get('/:id/ics', optionalAuth, getEventIcs);
|
||||
router.get('/by-alias/:redirectUrl', getEventByAlias);
|
||||
|
||||
// Create/Update/Delete event
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary } = require('../controllers/feedbackController');
|
||||
const { protect, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/public/:token', getPublicForm);
|
||||
router.post('/public/:token', submitPublicForm);
|
||||
router.get('/summary', protect, supervisor, getSummary);
|
||||
router.get('/event/:eventId', protect, supervisor, getEventFeedback);
|
||||
router.put('/event/:eventId', protect, supervisor, saveEventFeedback);
|
||||
router.delete('/event/:eventId', protect, supervisor, deleteEventFeedback);
|
||||
router.post('/event/:eventId/send-now', protect, supervisor, sendNow);
|
||||
|
||||
module.exports = router;
|
||||
@@ -13,6 +13,8 @@ const {
|
||||
submitFormResponses, replaceFormResponses,
|
||||
getFormDraft, saveFormDraft,
|
||||
sendInvoice,
|
||||
applyCoupon, removeCoupon,
|
||||
updateAttendees, sendAttendeeFormLink,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -22,6 +24,10 @@ router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, sendInvoice);
|
||||
router.post('/:id/coupon', protect, applyCoupon);
|
||||
router.delete('/:id/coupon', protect, staff, removeCoupon);
|
||||
router.put('/:id/attendees', protect, updateAttendees);
|
||||
router.post('/:id/attendees/:attendeeId/send-form-link', protect, sendAttendeeFormLink);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, getRegistrationById);
|
||||
|
||||
@@ -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,13 @@ const {
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
getScanStats,
|
||||
transferTicketHandler,
|
||||
cancelTransferHandler,
|
||||
getTransferOffer,
|
||||
acceptTransferOffer,
|
||||
declineTransferOffer,
|
||||
getTicketTransfers
|
||||
} = require('../controllers/ticketController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -21,6 +27,13 @@ const { protect, admin, supervisor, staff } = require('../middleware/authMiddlew
|
||||
router.get('/mytickets', protect, getUserTickets);
|
||||
router.get('/:id', protect, getTicketById);
|
||||
router.post('/email', protect, emailTickets);
|
||||
router.post('/:id/transfer', protect, transferTicketHandler);
|
||||
router.delete('/:id/transfer', protect, cancelTransferHandler);
|
||||
// Transfer links (public: the person the ticket is offered to)
|
||||
router.get('/transfer-offer/:token', getTransferOffer);
|
||||
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
|
||||
router.post('/transfer-offer/:token/decline', declineTransferOffer);
|
||||
router.get('/:id/transfers', protect, getTicketTransfers);
|
||||
|
||||
// Staff routes
|
||||
router.post('/send-to', protect, staff, sendTicketsTo);
|
||||
|
||||
@@ -3,6 +3,12 @@ const router = express.Router();
|
||||
const {
|
||||
registerUser,
|
||||
loginUser,
|
||||
verifyLoginTwoFactor,
|
||||
resendLoginTwoFactor,
|
||||
startEnableTwoFactor,
|
||||
confirmEnableTwoFactor,
|
||||
disableTwoFactor,
|
||||
verifyPassword,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
@@ -24,6 +30,8 @@ const { protect, admin, supervisor, loginLimiter} = require('../middleware/authM
|
||||
// Public routes
|
||||
router.post('/', registerUser);
|
||||
router.post('/login', loginLimiter, loginUser);
|
||||
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
|
||||
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
|
||||
router.post('/forgot', requestPasswordReset);
|
||||
router.post('/reset', resetPassword);
|
||||
router.post('/activate', activateAccount);
|
||||
@@ -36,6 +44,10 @@ router.route('/profile')
|
||||
router.post('/revoke-sessions', protect, revokeMySession);
|
||||
router.post('/close-account', protect, closeAccount);
|
||||
router.get('/activity', protect, getMyActivity);
|
||||
router.post('/verify-password', protect, loginLimiter, verifyPassword);
|
||||
router.post('/2fa/enable', protect, loginLimiter, startEnableTwoFactor);
|
||||
router.post('/2fa/confirm', protect, loginLimiter, confirmEnableTwoFactor);
|
||||
router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
|
||||
|
||||
// Admin routes
|
||||
router.route('/')
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
|
||||
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// Claim links are credentials, so keep guessing slow.
|
||||
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.post('/', optionalAuth, joinWaitlist);
|
||||
router.get('/mine', protect, myWaitlist);
|
||||
router.get('/claim/:token', claimLimiter, claimInfo);
|
||||
router.get('/events/:eventId', protect, staff, eventWaitlist);
|
||||
router.post('/:id/offer', protect, supervisor, offerManually);
|
||||
router.post('/:id/move', protect, supervisor, moveEntry);
|
||||
router.post('/:id/remove', protect, supervisor, removeEntry);
|
||||
router.delete('/:id', protect, leaveWaitlist);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook } = require('../controllers/yocoController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/config', protect, admin, getConfig);
|
||||
router.put('/config', protect, admin, updateConfig);
|
||||
router.get('/webhooks', protect, admin, getWebhooks);
|
||||
router.post('/webhooks', protect, admin, createWebhook);
|
||||
router.delete('/webhooks/:id', protect, admin, removeWebhook);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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);
|
||||
// Email and phone pointing at two different accounts: go with the email's account,
|
||||
// without saying so — the person typing the details mustn't learn who has an account.
|
||||
if (match.conflict) return { user: match.emailUser, created: false };
|
||||
if (match.user) return { user: match.user, created: false };
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { hashPassword } = require('../config/auth');
|
||||
const validEmail = hasValidEmail(email);
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
email: validEmail ? String(email).trim() : `guest+${uuidv4().slice(0, 8)}@guest.local`,
|
||||
password: await hashPassword(uuidv4()),
|
||||
phoneNumber: match.phone || null,
|
||||
isActive: false,
|
||||
notificationPreference: validEmail && match.phone ? 'both' : validEmail ? 'email' : 'whatsapp',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { user, created: true };
|
||||
}
|
||||
|
||||
module.exports = { findAccountByContact, findOrCreateAccount, hasValidEmail };
|
||||
@@ -0,0 +1,52 @@
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// Fire-and-forget by design — a logging failure must never break the underlying admin
|
||||
// action, so this swallows its own errors rather than propagating them to the caller
|
||||
// (same posture as logSecurityEvent).
|
||||
async function logAdminAction({ actorId, actorRole, action, targetType, targetId, metadata, ip }) {
|
||||
try {
|
||||
await prisma.adminAuditLog.create({
|
||||
data: {
|
||||
actorId: actorId || null,
|
||||
actorRole,
|
||||
action,
|
||||
targetType,
|
||||
targetId: targetId || null,
|
||||
metadata: metadata || undefined,
|
||||
ip: ip || null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to log admin action:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
|
||||
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
|
||||
const where = {};
|
||||
if (actorId) where.actorId = actorId;
|
||||
if (action) where.action = action;
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
if (to) where.createdAt.lte = new Date(to);
|
||||
}
|
||||
|
||||
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.adminAuditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
skip,
|
||||
include: { actor: { select: { id: true, name: true, email: true } } },
|
||||
}),
|
||||
prisma.adminAuditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
|
||||
}
|
||||
|
||||
module.exports = { logAdminAction, getAdminAuditLog };
|
||||
@@ -0,0 +1,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,220 @@
|
||||
/**
|
||||
* Registration funnel — first-party, consent-based tracking.
|
||||
*
|
||||
* The browser only tracks after the visitor accepts cookies: it keeps a random session
|
||||
* id and reports 'view' (event page) and 'start' (registration page). 'submit' is
|
||||
* recorded here when a registration arrives carrying that session id, and 'paid' is
|
||||
* read from that registration's status, so no personal data is stored with the events —
|
||||
* just a stage, a traffic source and a device type (worked out from the User-Agent,
|
||||
* which isn't kept).
|
||||
*
|
||||
* Counts are "reached": a session counts at a stage if it got that far or further, so a
|
||||
* visitor who went straight to the registration page still counts as a view. Raw rows
|
||||
* older than 90 days are rolled up into AnalyticsDaily and deleted.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const STAGES = ['view', 'start', 'submit', 'paid'];
|
||||
const TRACKED = ['view', 'start'];
|
||||
const RAW_DAYS = 90;
|
||||
const SESSION_RE = /^[a-zA-Z0-9-]{16,64}$/;
|
||||
|
||||
const dayOf = (d) => new Date(d).toISOString().slice(0, 10);
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const validSession = (sid) => typeof sid === 'string' && SESSION_RE.test(sid);
|
||||
|
||||
/** 'mobile' | 'tablet' | 'desktop', or null for bots. Pure. */
|
||||
function deviceFromUA(ua) {
|
||||
const s = String(ua || '');
|
||||
if (!s || /bot|crawl|spider|slurp|preview|headless|lighthouse/i.test(s)) return null;
|
||||
if (/iPad|Tablet|PlayBook|Silk|(Android(?!.*Mobile))/i.test(s)) return 'tablet';
|
||||
if (/Mobi|iPhone|iPod|Android|Windows Phone/i.test(s)) return 'mobile';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/** A traffic source label: utm_source / short-link alias, tidied; 'direct' when none. Pure. */
|
||||
function cleanSource(src) {
|
||||
const s = String(src || '').trim().toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 50);
|
||||
return s || 'direct';
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per session: the furthest stage reached, its source/device, and the day each
|
||||
* stage was reached. Pure.
|
||||
* @param {Array} events - [{ sessionId, stage, createdAt, source, device }]
|
||||
* @param {Set} paidSessions - session ids whose registration is paid
|
||||
*/
|
||||
function sessionsFrom(events, paidSessions = new Set()) {
|
||||
const map = new Map();
|
||||
for (const e of [...events].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))) {
|
||||
const s = map.get(e.sessionId) || { sessionId: e.sessionId, reach: -1, source: null, device: null, days: {} };
|
||||
const idx = STAGES.indexOf(e.stage);
|
||||
if (idx > s.reach) s.reach = idx;
|
||||
if (!s.days[e.stage]) s.days[e.stage] = dayOf(e.createdAt);
|
||||
if (!s.source && e.source) s.source = e.source;
|
||||
if (!s.device && e.device) s.device = e.device;
|
||||
map.set(e.sessionId, s);
|
||||
}
|
||||
for (const s of map.values()) {
|
||||
if (paidSessions.has(s.sessionId) && s.reach >= 2) { s.reach = 3; s.days.paid = s.days.submit; }
|
||||
// A stage reached without its own event (e.g. no 'view' for a direct link) takes the
|
||||
// day of the next stage that was recorded.
|
||||
for (let i = STAGES.length - 1; i >= 0; i--) {
|
||||
if (i <= s.reach && !s.days[STAGES[i]]) s.days[STAGES[i]] = s.days[STAGES[i + 1]];
|
||||
}
|
||||
s.source = s.source || 'direct';
|
||||
s.device = s.device || 'unknown';
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
/** Rows for AnalyticsDaily: sessions reaching each stage per day/source/device. Pure. */
|
||||
function rollupRows(sessions) {
|
||||
const by = new Map();
|
||||
for (const s of sessions) {
|
||||
for (let i = 0; i <= s.reach; i++) {
|
||||
const key = [s.days[STAGES[i]], STAGES[i], s.source, s.device].join('|');
|
||||
by.set(key, (by.get(key) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...by.entries()].map(([key, n]) => {
|
||||
const [day, stage, source, device] = key.split('|');
|
||||
return { day, stage, source, device, sessions: n };
|
||||
});
|
||||
}
|
||||
|
||||
const emptyCounts = () => ({ view: 0, start: 0, submit: 0, paid: 0 });
|
||||
|
||||
/**
|
||||
* The funnel report from raw sessions plus rolled-up rows. Pure.
|
||||
* @returns {{ totals, conversion, trend, bySource, byDevice }}
|
||||
*/
|
||||
function funnelReport(sessions, daily = []) {
|
||||
const rows = [...rollupRows(sessions), ...daily.map(d => ({ ...d, day: typeof d.day === 'string' ? d.day.slice(0, 10) : dayOf(d.day) }))];
|
||||
const totals = emptyCounts();
|
||||
const trend = new Map(), bySource = new Map(), byDevice = new Map();
|
||||
const add = (map, key, stage, n) => { const c = map.get(key) || emptyCounts(); c[stage] += n; map.set(key, c); };
|
||||
for (const r of rows) {
|
||||
totals[r.stage] += r.sessions;
|
||||
add(trend, r.day, r.stage, r.sessions);
|
||||
add(bySource, r.source, r.stage, r.sessions);
|
||||
add(byDevice, r.device, r.stage, r.sessions);
|
||||
}
|
||||
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
|
||||
const list = (map) => [...map.entries()].map(([key, c]) => ({ key, ...c, conversion: pct(c.paid, c.view) })).sort((a, b) => b.view - a.view);
|
||||
return {
|
||||
totals,
|
||||
conversion: {
|
||||
start: pct(totals.start, totals.view), submit: pct(totals.submit, totals.view), paid: pct(totals.paid, totals.view),
|
||||
startToSubmit: pct(totals.submit, totals.start), submitToPaid: pct(totals.paid, totals.submit),
|
||||
},
|
||||
trend: [...trend.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, c]) => ({ day, ...c })),
|
||||
bySource: list(bySource),
|
||||
byDevice: list(byDevice),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Database ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Records a 'view' or 'start'. Repeats from the same session are ignored. */
|
||||
async function track({ sessionId, eventId, stage, source, userAgent }) {
|
||||
if (!validSession(sessionId) || !TRACKED.includes(stage) || typeof eventId !== 'string') return false;
|
||||
const device = deviceFromUA(userAgent);
|
||||
if (!device) return false; // bots
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
|
||||
if (!event) return false;
|
||||
await prisma.analyticsEvent.createMany({ data: [{ sessionId, eventId, stage, source: cleanSource(source), device }], skipDuplicates: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 'submit' for a registration made on the public registration page. */
|
||||
async function recordSubmit(registration, sessionId) {
|
||||
if (!validSession(sessionId) || !registration?.id) return;
|
||||
const first = await prisma.analyticsEvent.findFirst({ where: { sessionId, eventId: registration.eventId }, orderBy: { createdAt: 'asc' } });
|
||||
await prisma.registration.updateMany({ where: { id: registration.id, analyticsSessionId: null }, data: { analyticsSessionId: sessionId } });
|
||||
await prisma.analyticsEvent.createMany({
|
||||
data: [{ sessionId, eventId: registration.eventId, stage: 'submit', source: first?.source || 'direct', device: first?.device || null }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function paidSessionsFor(eventIds, sessionIds) {
|
||||
if (!sessionIds.length) return new Set();
|
||||
const regs = await prisma.registration.findMany({
|
||||
where: { eventId: { in: eventIds }, status: 'paid', analyticsSessionId: { in: sessionIds } },
|
||||
select: { analyticsSessionId: true },
|
||||
});
|
||||
return new Set(regs.map(r => r.analyticsSessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* The funnel for some events between two dates (by the day each stage was reached).
|
||||
* Adds a by-ticket-option breakdown from the tracked registrations.
|
||||
*/
|
||||
async function funnelFor(eventIds, { from, to } = {}) {
|
||||
const range = {};
|
||||
if (from) range.gte = new Date(from);
|
||||
if (to) { const t = new Date(to); t.setHours(23, 59, 59, 999); range.lte = t; }
|
||||
const where = { eventId: { in: eventIds }, ...(from || to ? { createdAt: range } : {}) };
|
||||
const events = await prisma.analyticsEvent.findMany({ where, select: { sessionId: true, stage: true, createdAt: true, source: true, device: true, eventId: true } });
|
||||
// A session is per event (the same browser can look at several events).
|
||||
const keyed = events.map(e => ({ ...e, sessionId: `${e.eventId}:${e.sessionId}` }));
|
||||
const paidRaw = await paidSessionsFor(eventIds, [...new Set(events.map(e => e.sessionId))]);
|
||||
const paid = new Set(events.filter(e => paidRaw.has(e.sessionId)).map(e => `${e.eventId}:${e.sessionId}`));
|
||||
const daily = await prisma.analyticsDaily.findMany({
|
||||
where: { eventId: { in: eventIds }, ...(from || to ? { day: range } : {}) },
|
||||
select: { day: true, stage: true, source: true, device: true, sessions: true },
|
||||
});
|
||||
const report = funnelReport(sessionsFrom(keyed, paid), daily);
|
||||
|
||||
const regs = await prisma.registration.findMany({
|
||||
where: { eventId: { in: eventIds }, analyticsSessionId: { not: null }, status: { not: 'cancelled' }, ...(from || to ? { createdAt: range } : {}) },
|
||||
select: { status: true, registrationOptions: { select: { quantity: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } } },
|
||||
});
|
||||
const byOption = new Map();
|
||||
for (const r of regs) {
|
||||
for (const ro of r.registrationOptions) {
|
||||
const key = ro.variant ? `${ro.eventOption.name} — ${ro.variant.name}` : ro.eventOption.name;
|
||||
const c = byOption.get(key) || { key, submitted: 0, paid: 0 };
|
||||
c.submitted += 1;
|
||||
if (r.status === 'paid') c.paid += 1;
|
||||
byOption.set(key, c);
|
||||
}
|
||||
}
|
||||
return { ...report, byOption: [...byOption.values()].sort((a, b) => b.submitted - a.submitted) };
|
||||
}
|
||||
|
||||
/** Rolls up sessions whose last activity is older than 90 days, then deletes their rows. */
|
||||
async function rollupOldEvents(now = new Date()) {
|
||||
const cutoff = new Date(now.getTime() - RAW_DAYS * 864e5);
|
||||
const groups = await prisma.analyticsEvent.groupBy({ by: ['eventId', 'sessionId'], _max: { createdAt: true }, having: { createdAt: { _max: { lt: cutoff } } } });
|
||||
if (groups.length === 0) return 0;
|
||||
const byEvent = new Map();
|
||||
for (const g of groups) byEvent.set(g.eventId, [...(byEvent.get(g.eventId) || []), g.sessionId]);
|
||||
let rolled = 0;
|
||||
for (const [eventId, sessionIds] of byEvent) {
|
||||
for (let i = 0; i < sessionIds.length; i += 500) {
|
||||
const batch = sessionIds.slice(i, i + 500);
|
||||
const events = await prisma.analyticsEvent.findMany({ where: { eventId, sessionId: { in: batch } } });
|
||||
const paid = await paidSessionsFor([eventId], batch);
|
||||
const rows = rollupRows(sessionsFrom(events, paid));
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const r of rows) {
|
||||
await tx.analyticsDaily.upsert({
|
||||
where: { eventId_day_stage_source_device: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device } },
|
||||
update: { sessions: { increment: r.sessions } },
|
||||
create: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device, sessions: r.sessions },
|
||||
});
|
||||
}
|
||||
await tx.analyticsEvent.deleteMany({ where: { eventId, sessionId: { in: batch } } });
|
||||
});
|
||||
rolled += batch.length;
|
||||
}
|
||||
}
|
||||
return rolled;
|
||||
}
|
||||
|
||||
module.exports = { STAGES, RAW_DAYS, validSession, deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport, track, recordSubmit, funnelFor, rollupOldEvents };
|
||||
@@ -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,87 @@
|
||||
const { spawn } = require('child_process');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const zlib = require('zlib');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
|
||||
const BACKUP_DIR = path.join(__dirname, '..', '..', 'backups');
|
||||
|
||||
// Local-disk-only by design (no offsite/cloud upload) — matches the exact filename shape
|
||||
// this module generates, and is reused to validate download requests against path traversal.
|
||||
const BACKUP_FILENAME_RE = /^backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.sql\.gz$/;
|
||||
|
||||
function ensureBackupDir() {
|
||||
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function backupFilename(date = new Date()) {
|
||||
return `backup-${date.toISOString().replace(/[:.]/g, '-')}.sql.gz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `pg_dump` against DATABASE_URL, gzip its output, and write it to backend/backups/.
|
||||
* Requires the `pg_dump` binary to be installed on this host (Postgres itself is hosted
|
||||
* separately) — a deploy-environment prerequisite, not something this code can satisfy.
|
||||
*
|
||||
* @returns {Promise<{ filename: string, path: string }>}
|
||||
*/
|
||||
async function runBackup() {
|
||||
ensureBackupDir();
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error('DATABASE_URL is not configured');
|
||||
|
||||
const filename = backupFilename();
|
||||
const filePath = path.join(BACKUP_DIR, filename);
|
||||
|
||||
const pgDump = spawn('pg_dump', [databaseUrl, '--no-owner', '--no-privileges'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
pgDump.stderr.on('data', (d) => { stderr += d.toString(); });
|
||||
|
||||
const exitPromise = new Promise((resolve, reject) => {
|
||||
pgDump.on('error', (err) => reject(new Error(`Failed to start pg_dump: ${err.message}. Is it installed on this host?`)));
|
||||
pgDump.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`pg_dump exited with code ${code}: ${stderr.slice(0, 500)}`));
|
||||
});
|
||||
});
|
||||
|
||||
const pipelinePromise = pipeline(pgDump.stdout, zlib.createGzip(), fs.createWriteStream(filePath));
|
||||
|
||||
try {
|
||||
// Both must succeed: the process exiting cleanly, and the gzip write finishing —
|
||||
// a mid-dump failure must not leave a truncated file looking like a real backup.
|
||||
await Promise.all([exitPromise, pipelinePromise]);
|
||||
} catch (err) {
|
||||
await fs.promises.unlink(filePath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const retainCount = parseInt(getSettingSync('backup_retain_count', '14'), 10) || 14;
|
||||
await rotateBackups(retainCount);
|
||||
|
||||
return { filename, path: filePath };
|
||||
}
|
||||
|
||||
/** @returns {Promise<Array<{ filename: string, size: number, createdAt: Date }>>} newest first */
|
||||
async function listBackups() {
|
||||
ensureBackupDir();
|
||||
const files = await fs.promises.readdir(BACKUP_DIR);
|
||||
const backups = await Promise.all(
|
||||
files.filter((f) => BACKUP_FILENAME_RE.test(f)).map(async (f) => {
|
||||
const stat = await fs.promises.stat(path.join(BACKUP_DIR, f));
|
||||
return { filename: f, size: stat.size, createdAt: stat.mtime };
|
||||
})
|
||||
);
|
||||
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
/** Deletes backups beyond the retention count, oldest first. */
|
||||
async function rotateBackups(retainCount) {
|
||||
const backups = await listBackups();
|
||||
const toDelete = backups.slice(retainCount);
|
||||
await Promise.all(toDelete.map((b) => fs.promises.unlink(path.join(BACKUP_DIR, b.filename)).catch(() => {})));
|
||||
return { deleted: toDelete.length };
|
||||
}
|
||||
|
||||
module.exports = { runBackup, listBackups, rotateBackups, BACKUP_DIR, BACKUP_FILENAME_RE };
|
||||
@@ -72,7 +72,7 @@ async function computeEventFinancials(eventId) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const [payments, costs, tickets, salesRows, history] = await Promise.all([
|
||||
const [payments, costs, tickets, salesRows, history, discountAgg] = await Promise.all([
|
||||
prisma.payment.findMany({
|
||||
where: { OR: [{ eventId }, { registration: { eventId } }] }
|
||||
}),
|
||||
@@ -92,7 +92,9 @@ async function computeEventFinancials(eventId) {
|
||||
performedBy: { select: { id: true, name: true, email: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
}),
|
||||
// Coupon discounts on the same paid registrations salesByOption counts.
|
||||
prisma.registration.aggregate({ where: { eventId, status: 'paid' }, _sum: { discountTotal: true } })
|
||||
]);
|
||||
|
||||
// Ticket quantity sold per EventOption (used to price per-item costs)
|
||||
@@ -204,6 +206,8 @@ async function computeEventFinancials(eventId) {
|
||||
salesByOptionMap[opt.id].revenue += computeOptionLineTotal(ro, null, new Date());
|
||||
}
|
||||
const salesByOption = Object.values(salesByOptionMap);
|
||||
// salesByOption is at list (or early-bird) price; this is what coupon codes took off it.
|
||||
const discountsTotal = Number(discountAgg?._sum?.discountTotal || 0);
|
||||
|
||||
return {
|
||||
event,
|
||||
@@ -222,6 +226,7 @@ async function computeEventFinancials(eventId) {
|
||||
unallocatedDonationsTotal,
|
||||
totalDonations,
|
||||
salesByOption,
|
||||
discountsTotal,
|
||||
history
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Parental consent for youth events.
|
||||
*
|
||||
* Rules:
|
||||
* - On an event with requiresParentalConsent, every attendee gives a date of birth.
|
||||
* Anyone under 18 on the event's start date needs a parent/guardian (name, email,
|
||||
* phone), who is sent a consent request by email AND WhatsApp — regardless of anyone's
|
||||
* notification preference.
|
||||
* - A request's link token is random; only its HMAC is stored. Links expire when the
|
||||
* event ends and can be re-sent (which issues a new token).
|
||||
* - The parent's answer records the time, IP, device, typed name, and the exact consent
|
||||
* text and version they saw. Medical/emergency details are stored encrypted and purged
|
||||
* a set number of days after the event (setting consent_medical_retention_days).
|
||||
* - Tickets are issued as normal but can't be checked in until consent is given — the
|
||||
* latest request for the attendee decides. On a consent event, a ticket with no named
|
||||
* attendee is treated as missing consent. Supervisors can override (audited).
|
||||
* - Reminders go 2 days after the request, then every 3 days, until answered or the event.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const FIRST_REMINDER_AFTER = 2 * DAY;
|
||||
const REMINDER_EVERY = 3 * DAY;
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
function consentError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Whole years old on `onDate`. */
|
||||
function ageOn(dateOfBirth, onDate) {
|
||||
const dob = new Date(dateOfBirth);
|
||||
const on = new Date(onDate);
|
||||
let age = on.getUTCFullYear() - dob.getUTCFullYear();
|
||||
const beforeBirthday = on.getUTCMonth() < dob.getUTCMonth()
|
||||
|| (on.getUTCMonth() === dob.getUTCMonth() && on.getUTCDate() < dob.getUTCDate());
|
||||
if (beforeBirthday) age -= 1;
|
||||
return age;
|
||||
}
|
||||
|
||||
/** Under 18 on the event's start date. */
|
||||
function isMinor(dateOfBirth, eventStart) {
|
||||
if (!dateOfBirth) return false;
|
||||
return ageOn(dateOfBirth, eventStart) < 18;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consent state for one attendee: 'not_required' | 'missing' | 'pending' | 'given' | 'declined'.
|
||||
* `latest` is the attendee's most recent ConsentRequest (or null).
|
||||
*/
|
||||
function consentStatusFor(attendee, event, latest) {
|
||||
if (!event?.requiresParentalConsent) return 'not_required';
|
||||
if (!attendee) return 'missing'; // unnamed place on a consent event
|
||||
if (!isMinor(attendee.dateOfBirth, event.startDate)) return 'not_required';
|
||||
if (!latest) return 'missing';
|
||||
return latest.status;
|
||||
}
|
||||
|
||||
/** Whether a pending request is due a reminder now. */
|
||||
function reminderDue(request, now = new Date()) {
|
||||
if (!request || request.status !== 'pending') return false;
|
||||
const t = new Date(now).getTime();
|
||||
if (t >= new Date(request.expiresAt).getTime()) return false;
|
||||
const gap = request.remindersSent === 0 ? FIRST_REMINDER_AFTER : REMINDER_EVERY;
|
||||
return t - new Date(request.lastSentAt).getTime() >= gap;
|
||||
}
|
||||
|
||||
const str = (v, max = 500) => String(v ?? '').trim().slice(0, max);
|
||||
|
||||
/**
|
||||
* Checks a parent's answer on the consent page. Pure. Returns the cleaned answer.
|
||||
* @param {object} body - { decision: 'give'|'decline', signedName, isGuardian, medical, emergency, photoConsent }
|
||||
* @param {object} event - with consentCollectMedical / Emergency / Photo
|
||||
*/
|
||||
function validateConsentAnswer(body, event) {
|
||||
const decision = body?.decision;
|
||||
if (decision !== 'give' && decision !== 'decline') throw consentError('Choose to give or decline consent.');
|
||||
const signedName = str(body.signedName, 200);
|
||||
if (signedName.length < 2) throw consentError('Type your full name to sign.');
|
||||
if (body.isGuardian !== true) throw consentError('Confirm that you are the parent or legal guardian.');
|
||||
|
||||
const out = { decision, signedName, medical: null, emergency: null, photoConsent: null };
|
||||
if (decision === 'decline') return out;
|
||||
|
||||
if (event.consentCollectMedical) {
|
||||
const m = body.medical || {};
|
||||
out.medical = {
|
||||
allergies: str(m.allergies), medication: str(m.medication),
|
||||
conditions: str(m.conditions), medicalAid: str(m.medicalAid, 200),
|
||||
};
|
||||
}
|
||||
if (event.consentCollectEmergency) {
|
||||
const em = body.emergency || {};
|
||||
out.emergency = { name: str(em.name, 200), phone: str(em.phone, 40), relationship: str(em.relationship, 100) };
|
||||
if (!out.emergency.name || !out.emergency.phone) throw consentError('Add an emergency contact name and phone number.');
|
||||
}
|
||||
if (event.consentCollectPhoto) {
|
||||
if (body.photoConsent !== true && body.photoConsent !== false) throw consentError('Answer the photo consent question.');
|
||||
out.photoConsent = body.photoConsent;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Tokens ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`consent:${token}`).digest('hex');
|
||||
const newToken = () => crypto.randomBytes(24).toString('hex');
|
||||
|
||||
function consentLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/consent/${token}`;
|
||||
}
|
||||
|
||||
// ─── Encryption of medical/emergency details ──────────────────────────────────
|
||||
|
||||
function sealJson(value) {
|
||||
if (!value) return null;
|
||||
const { encrypt } = require('./encryption');
|
||||
return encrypt(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function openJson(value) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const { decrypt } = require('./encryption');
|
||||
return JSON.parse(decrypt(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Latest request per attendee id. */
|
||||
async function latestRequestsFor(attendeeIds, db = prisma) {
|
||||
if (!attendeeIds.length) return new Map();
|
||||
const rows = await db.consentRequest.findMany({ where: { attendeeId: { in: attendeeIds } }, orderBy: { createdAt: 'desc' } });
|
||||
const out = new Map();
|
||||
for (const r of rows) if (!out.has(r.attendeeId)) out.set(r.attendeeId, r);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function sendConsentMessage(request, token, { reminder = false } = {}) {
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||||
const { waTextAny } = require('./notify');
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
|
||||
const event = request.event || await prisma.event.findUnique({ where: { id: request.eventId } });
|
||||
const attendee = request.attendee || await prisma.attendee.findUnique({ where: { id: request.attendeeId } });
|
||||
const link = consentLink(token);
|
||||
const when = event?.startDate ? new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
|
||||
const subject = `${reminder ? 'Reminder: ' : ''}Consent needed for ${attendee.name} — ${event.title}`;
|
||||
const intro = `${attendee.name} has been registered for <strong>${event.title}</strong>${when ? ` on <strong>${when}</strong>` : ''}. As their parent or guardian, please review the details and give (or decline) consent.`;
|
||||
const sent = [];
|
||||
|
||||
if (request.parentEmail && request.parentEmail.includes('@')) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">${reminder ? 'A reminder: ' : ''}Parental consent needed</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.parentName || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${intro}</p>
|
||||
${ctaButton('Review and give consent', link)}
|
||||
${fallbackLink(link)}
|
||||
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">${attendee.name} can't be checked in at the event until consent is given.</p>
|
||||
`, { preheader: `Consent needed for ${attendee.name}` });
|
||||
try {
|
||||
await sendMail({ to: request.parentEmail, subject, html, text: `Hi ${request.parentName || 'there'},\n\n${intro.replace(/<[^>]+>/g, '')}\n\nReview and give consent: ${link}\n\n${orgName}` });
|
||||
sent.push('email');
|
||||
} catch (e) {
|
||||
console.warn('[consent] email failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
if (request.parentPhone && isValidZAPhone(request.parentPhone)) {
|
||||
await waTextAny({ name: request.parentName, phoneNumber: request.parentPhone },
|
||||
`${reminder ? '⏰ Reminder: ' : ''}Hi ${request.parentName || 'there'}, ${attendee.name} has been registered for *${event.title}*${when ? ` on ${when}` : ''}. Please give (or decline) parental consent here: ${link}`);
|
||||
sent.push('WhatsApp');
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates (and sends) a consent request for an attendee. Earlier requests stay as the
|
||||
* record of what was agreed before; this one now decides check-in.
|
||||
*/
|
||||
async function createConsentRequest(attendee, event) {
|
||||
const token = newToken();
|
||||
const request = await prisma.consentRequest.create({
|
||||
data: {
|
||||
attendeeId: attendee.id,
|
||||
eventId: event.id,
|
||||
tokenHash: hashToken(token),
|
||||
textVersion: event.consentTextVersion || 1,
|
||||
consentText: event.consentText || '',
|
||||
parentName: attendee.parentName,
|
||||
parentEmail: attendee.parentEmail,
|
||||
parentPhone: attendee.parentPhone,
|
||||
expiresAt: event.endDate,
|
||||
},
|
||||
});
|
||||
const sent = await sendConsentMessage({ ...request, event, attendee }, token);
|
||||
return { request, sent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure every under-18 attendee on a registration has a consent request — creates
|
||||
* and sends one for anyone who has none yet, or whose parent details have changed since.
|
||||
*/
|
||||
async function ensureConsentRequests(registrationId) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { event: true, attendees: true } });
|
||||
if (!reg || !reg.event.requiresParentalConsent || reg.status === 'cancelled') return 0;
|
||||
const minors = reg.attendees.filter(a => isMinor(a.dateOfBirth, reg.event.startDate));
|
||||
const latest = await latestRequestsFor(minors.map(a => a.id));
|
||||
let created = 0;
|
||||
for (const a of minors) {
|
||||
const r = latest.get(a.id);
|
||||
const parentChanged = r && (r.parentEmail !== a.parentEmail || r.parentPhone !== a.parentPhone);
|
||||
if (!r || (parentChanged && r.status !== 'given')) {
|
||||
await createConsentRequest(a, reg.event);
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Re-sends a request with a fresh link (the old link stops working). */
|
||||
async function resendConsentRequest(requestId) {
|
||||
const request = await prisma.consentRequest.findUnique({ where: { id: requestId }, include: { event: true, attendee: true } });
|
||||
if (!request) throw consentError('Consent request not found', 404);
|
||||
if (request.status !== 'pending') throw consentError(`Consent has already been ${request.status}.`);
|
||||
if (new Date(request.expiresAt) < new Date()) throw consentError('This event has ended.');
|
||||
const token = newToken();
|
||||
await prisma.consentRequest.update({ where: { id: request.id }, data: { tokenHash: hashToken(token), lastSentAt: new Date() } });
|
||||
return sendConsentMessage(request, token, { reminder: true });
|
||||
}
|
||||
|
||||
async function findRequestByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 100) return null;
|
||||
return prisma.consentRequest.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: { event: true, attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consent state for each ticket id — for the scan gate and staff screens.
|
||||
* @returns {Promise<Map<string, string>>} ticketId → consent status
|
||||
*/
|
||||
async function consentStatusForTickets(ticketIds) {
|
||||
const tickets = await prisma.ticket.findMany({ where: { id: { in: ticketIds } }, include: { event: true, attendee: true } });
|
||||
const latest = await latestRequestsFor(tickets.map(t => t.attendeeId).filter(Boolean));
|
||||
const out = new Map();
|
||||
for (const t of tickets) {
|
||||
out.set(t.id, consentStatusFor(t.attendee, t.event, t.attendeeId ? latest.get(t.attendeeId) : null));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Daily job: reminders for unanswered requests. */
|
||||
async function sendConsentReminders(now = new Date()) {
|
||||
const pending = await prisma.consentRequest.findMany({
|
||||
where: { status: 'pending', expiresAt: { gt: now } },
|
||||
include: { event: true, attendee: true },
|
||||
});
|
||||
// Only the latest request per attendee is still live.
|
||||
const latest = await latestRequestsFor([...new Set(pending.map(r => r.attendeeId))]);
|
||||
let sent = 0;
|
||||
for (const r of pending) {
|
||||
if (latest.get(r.attendeeId)?.id !== r.id || !reminderDue(r, now)) continue;
|
||||
if (new Date(r.event.startDate) <= now) continue; // the event has started
|
||||
const token = newToken();
|
||||
await prisma.consentRequest.update({ where: { id: r.id }, data: { tokenHash: hashToken(token), lastSentAt: now, remindersSent: { increment: 1 } } });
|
||||
await sendConsentMessage(r, token, { reminder: true }).catch(e => console.warn('[consent] reminder failed:', e?.message || e));
|
||||
sent += 1;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Daily job: delete medical/emergency details a set number of days after the event. */
|
||||
async function purgeConsentMedicalData(now = new Date()) {
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const days = Math.max(0, parseInt(getSettingSync('consent_medical_retention_days', String(DEFAULT_RETENTION_DAYS)), 10) || DEFAULT_RETENTION_DAYS);
|
||||
const cutoff = new Date(new Date(now).getTime() - days * DAY);
|
||||
const res = await prisma.consentRequest.updateMany({
|
||||
where: { medicalPurgedAt: null, event: { endDate: { lt: cutoff } }, OR: [{ medical: { not: null } }, { emergency: { not: null } }] },
|
||||
data: { medical: null, emergency: null, medicalPurgedAt: now },
|
||||
});
|
||||
return res.count;
|
||||
}
|
||||
|
||||
/** Clears parent, medical and emergency details for a person's registrations (anonymise). */
|
||||
async function clearConsentDataForUser(userId, db = prisma) {
|
||||
const attendees = await db.attendee.findMany({ where: { OR: [{ userId }, { registration: { userId } }] }, select: { id: true } });
|
||||
const ids = attendees.map(a => a.id);
|
||||
if (!ids.length) return 0;
|
||||
await db.attendee.updateMany({ where: { id: { in: ids } }, data: { parentName: null, parentEmail: null, parentPhone: null, dateOfBirth: null, email: null, phone: null } });
|
||||
const res = await db.consentRequest.updateMany({
|
||||
where: { attendeeId: { in: ids } },
|
||||
data: { medical: null, emergency: null, parentName: null, parentEmail: null, parentPhone: null, ip: null, device: null, medicalPurgedAt: new Date() },
|
||||
});
|
||||
return res.count;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
consentError,
|
||||
ageOn,
|
||||
isMinor,
|
||||
consentStatusFor,
|
||||
reminderDue,
|
||||
validateConsentAnswer,
|
||||
hashToken,
|
||||
consentLink,
|
||||
sealJson,
|
||||
openJson,
|
||||
latestRequestsFor,
|
||||
createConsentRequest,
|
||||
ensureConsentRequests,
|
||||
resendConsentRequest,
|
||||
findRequestByToken,
|
||||
consentStatusForTickets,
|
||||
sendConsentReminders,
|
||||
purgeConsentMedicalData,
|
||||
clearConsentDataForUser,
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Coupon codes — percentage, fixed-amount and comp (100%) discounts on registrations.
|
||||
*
|
||||
* Rules:
|
||||
* - A registration can carry one active coupon. Its discount is stored as a
|
||||
* CouponRedemption (amount locked at apply time) and mirrored as the sum of active
|
||||
* redemptions on Registration.discountTotal, which computeRegistrationTotalDue
|
||||
* subtracts. Early-bird pricing refreshes never touch it.
|
||||
* - Editing a registration's items recalculates percentage and comp codes over the new
|
||||
* items; fixed-amount codes keep their amount (capped at what's in scope).
|
||||
* - Cancelling a registration releases its redemption, freeing the use.
|
||||
* - Scope: no CouponOption rows = the whole event; otherwise only matching options
|
||||
* (or one variant of an option).
|
||||
* - stackEarlyBird=false means "best price wins": the coupon is worked out against the
|
||||
* standard price, and only the part that beats the early-bird price is discounted.
|
||||
* - Restricted codes (users, roles, email domain) need a known account, so guests
|
||||
* can't use them; a match on any one restriction is enough.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
function normalizeCode(code) {
|
||||
return String(code || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
function couponError(message) {
|
||||
const err = new Error(message);
|
||||
err.isCouponError = true;
|
||||
err.statusCode = 400;
|
||||
return err;
|
||||
}
|
||||
|
||||
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
|
||||
|
||||
/**
|
||||
* Discountable lines from a registration — one per price tranche, so tickets bought at
|
||||
* different prices are each discounted at what was actually charged.
|
||||
* `basePrice` is the standard (non-early-bird) price, used for "best price wins".
|
||||
*/
|
||||
function buildCouponLines(registration) {
|
||||
const lines = [];
|
||||
for (const ro of registration?.registrationOptions || []) {
|
||||
const eo = ro.eventOption || {};
|
||||
const variant = ro.variantId ? (eo.variants || []).find(v => v.id === ro.variantId) : null;
|
||||
const standard = variant && variant.price !== null && variant.price !== undefined ? Number(variant.price) : Number(eo.price || 0);
|
||||
const tranches = ro.tranches && ro.tranches.length > 0
|
||||
? ro.tranches
|
||||
: [{ quantity: ro.quantity, priceSnapshot: ro.priceSnapshot ?? standard, appliedTierId: ro.appliedTierId }];
|
||||
for (const t of tranches) {
|
||||
const unitPrice = Number(t.priceSnapshot ?? standard);
|
||||
lines.push({
|
||||
eventOptionId: ro.eventOptionId,
|
||||
variantId: ro.variantId || null,
|
||||
quantity: Number(t.quantity || 0),
|
||||
unitPrice,
|
||||
// Only an early-bird tranche has a "standard" price above what was charged; a
|
||||
// staff price or plain price is already the base the coupon works from.
|
||||
basePrice: t.appliedTierId ? Math.max(standard, unitPrice) : unitPrice,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function lineInScope(line, scopes) {
|
||||
if (!scopes || scopes.length === 0) return true;
|
||||
return scopes.some(s => s.eventOptionId === line.eventOptionId && (!s.variantId || s.variantId === line.variantId));
|
||||
}
|
||||
|
||||
function scopedLines(coupon, lines) {
|
||||
return (lines || []).filter(l => lineInScope(l, coupon.options));
|
||||
}
|
||||
|
||||
/**
|
||||
* The discount a coupon gives on these lines, in rand, never more than what the in-scope
|
||||
* lines cost. Pure — used for previews, applying, and recalculating after edits.
|
||||
*/
|
||||
function computeCouponDiscount(coupon, lines) {
|
||||
const scoped = scopedLines(coupon, lines);
|
||||
const charged = scoped.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
if (charged <= 0) return 0;
|
||||
|
||||
let discount = 0;
|
||||
if (coupon.type === 'comp') {
|
||||
discount = charged;
|
||||
} else if (coupon.type === 'percent') {
|
||||
const pct = Math.min(100, Math.max(0, Number(coupon.value || 0))) / 100;
|
||||
if (coupon.stackEarlyBird) {
|
||||
discount = charged * pct;
|
||||
} else {
|
||||
discount = scoped.reduce((s, l) => s + (l.unitPrice - Math.min(l.unitPrice, l.basePrice * (1 - pct))) * l.quantity, 0);
|
||||
}
|
||||
} else if (coupon.type === 'fixed') {
|
||||
const amount = Math.max(0, Number(coupon.value || 0));
|
||||
if (coupon.stackEarlyBird) {
|
||||
discount = amount;
|
||||
} else {
|
||||
const base = scoped.reduce((s, l) => s + l.basePrice * l.quantity, 0);
|
||||
discount = charged - Math.max(0, base - amount);
|
||||
}
|
||||
}
|
||||
return round2(Math.min(charged, Math.max(0, discount)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a coupon can be used. Pure — the caller supplies the use counts.
|
||||
*
|
||||
* @param {object} coupon - with events[], options[], allowedUsers[]
|
||||
* @param {object} ctx - { eventId, user: {id, role, email}|null, now, usesTotal, usesByUser, lines,
|
||||
* deferRestrictions } — deferRestrictions lets staff preview a
|
||||
* restricted code before the attendee's account exists; it's
|
||||
* checked against the attendee when the registration is saved.
|
||||
* @returns {{ ok: true } | { ok: false, message: string }}
|
||||
*/
|
||||
function validateCoupon(coupon, ctx) {
|
||||
const no = (message) => ({ ok: false, message });
|
||||
if (!coupon) return no('That code isn\'t valid.');
|
||||
if (!coupon.active) return no('This code is no longer active.');
|
||||
const now = ctx.now ? new Date(ctx.now) : new Date();
|
||||
if (coupon.validFrom && now < new Date(coupon.validFrom)) return no('This code isn\'t valid yet.');
|
||||
if (coupon.validUntil && now > new Date(coupon.validUntil)) return no('This code has expired.');
|
||||
if ((coupon.events || []).length > 0 && !coupon.events.some(e => e.eventId === ctx.eventId)) {
|
||||
return no('This code isn\'t valid for this event.');
|
||||
}
|
||||
|
||||
const restrictedUsers = (coupon.allowedUsers || []).map(u => u.userId);
|
||||
const restrictedRoles = coupon.restrictRoles || [];
|
||||
const domain = String(coupon.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
|
||||
if ((restrictedUsers.length || restrictedRoles.length || domain) && !(ctx.deferRestrictions && !ctx.user)) {
|
||||
const user = ctx.user;
|
||||
if (!user) return no('Log in to use this code.');
|
||||
const email = String(user.email || '').toLowerCase();
|
||||
const allowed = restrictedUsers.includes(user.id)
|
||||
|| restrictedRoles.includes(user.role)
|
||||
|| (domain && email.endsWith(`@${domain}`));
|
||||
if (!allowed) return no('This code isn\'t available for your account.');
|
||||
}
|
||||
|
||||
if (coupon.maxUses !== null && coupon.maxUses !== undefined && (ctx.usesTotal || 0) >= coupon.maxUses) {
|
||||
return no('This code has been fully used.');
|
||||
}
|
||||
if (coupon.maxUsesPerUser !== null && coupon.maxUsesPerUser !== undefined && ctx.user && (ctx.usesByUser || 0) >= coupon.maxUsesPerUser) {
|
||||
return no('You\'ve already used this code.');
|
||||
}
|
||||
|
||||
const scopedQty = scopedLines(coupon, ctx.lines).reduce((s, l) => s + l.quantity, 0);
|
||||
if (scopedQty === 0) return no('This code doesn\'t apply to the items you\'ve chosen.');
|
||||
if (coupon.minQuantity && scopedQty < coupon.minQuantity) {
|
||||
return no(`This code needs at least ${coupon.minQuantity} qualifying ticket${coupon.minQuantity === 1 ? '' : 's'}.`);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Short human description, e.g. "20% off", "R50 off", "Free". */
|
||||
function describeCoupon(coupon) {
|
||||
if (!coupon) return '';
|
||||
if (coupon.type === 'comp') return 'Free';
|
||||
if (coupon.type === 'percent') return `${Number(coupon.value)}% off`;
|
||||
return `R${Number(coupon.value).toFixed(2)} off`;
|
||||
}
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const COUPON_INCLUDE = { events: true, options: true, allowedUsers: true };
|
||||
|
||||
const REGISTRATION_INCLUDE = {
|
||||
registrationOptions: { include: { tranches: true, eventOption: { include: { variants: true } } } },
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { include: COUPON_INCLUDE } } },
|
||||
user: { select: { id: true, role: true, email: true } },
|
||||
};
|
||||
|
||||
async function findCouponByCode(code, db = prisma) {
|
||||
const normalized = normalizeCode(code);
|
||||
if (!normalized) return null;
|
||||
return db.coupon.findUnique({ where: { code: normalized }, include: COUPON_INCLUDE });
|
||||
}
|
||||
|
||||
/** Active uses: unreleased redemptions on registrations that aren't cancelled. */
|
||||
async function countCouponUses(couponId, userId, db = prisma) {
|
||||
const base = { couponId, releasedAt: null, registration: { status: { not: 'cancelled' } } };
|
||||
const [usesTotal, usesByUser] = await Promise.all([
|
||||
db.couponRedemption.count({ where: base }),
|
||||
userId ? db.couponRedemption.count({ where: { ...base, userId } }) : Promise.resolve(0),
|
||||
]);
|
||||
return { usesTotal, usesByUser };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates `code` against hypothetical lines (nothing saved) and returns the discount it
|
||||
* would give. Throws a coupon error with a user-facing message when it can't be used.
|
||||
*/
|
||||
async function previewCoupon({ code, eventId, user, lines, deferRestrictions = false }) {
|
||||
const coupon = await findCouponByCode(code);
|
||||
const uses = coupon ? await countCouponUses(coupon.id, user?.id) : {};
|
||||
const check = validateCoupon(coupon, { eventId, user, lines, deferRestrictions, ...uses });
|
||||
if (!check.ok) throw couponError(check.message);
|
||||
return { coupon, discount: computeCouponDiscount(coupon, lines) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `code` to a saved registration: validates it against the registration's owner
|
||||
* and items, locks in the discount, and updates Registration.discountTotal. Runs under a
|
||||
* per-coupon advisory lock so the last available use can't be claimed twice.
|
||||
*/
|
||||
async function applyCouponToRegistration({ registrationId, code, appliedById = null }) {
|
||||
const normalized = normalizeCode(code);
|
||||
if (!normalized) throw couponError('Enter a code.');
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const coupon = await findCouponByCode(normalized, tx);
|
||||
if (!coupon) throw couponError('That code isn\'t valid.');
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${coupon.id}))`;
|
||||
|
||||
const registration = await tx.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
|
||||
if (!registration) throw couponError('Registration not found.');
|
||||
if (registration.couponRedemptions.length > 0) {
|
||||
throw couponError(registration.couponRedemptions[0].couponId === coupon.id
|
||||
? 'This code is already applied to this registration.'
|
||||
: 'A code has already been applied to this registration.');
|
||||
}
|
||||
|
||||
const lines = buildCouponLines(registration);
|
||||
const uses = await countCouponUses(coupon.id, registration.userId, tx);
|
||||
const check = validateCoupon(coupon, { eventId: registration.eventId, user: registration.user, lines, ...uses });
|
||||
if (!check.ok) throw couponError(check.message);
|
||||
|
||||
const discount = computeCouponDiscount(coupon, lines);
|
||||
const redemption = await tx.couponRedemption.create({
|
||||
data: { couponId: coupon.id, registrationId, userId: registration.userId, discountAmount: discount, appliedById },
|
||||
});
|
||||
await tx.registration.update({ where: { id: registrationId }, data: { discountTotal: discount, updatedAt: new Date() } });
|
||||
return { coupon, discount, redemption };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-works active discounts after a registration's items change: percentage and comp
|
||||
* codes are recalculated over the new items, fixed codes keep their amount (capped at
|
||||
* what's still in scope). Pass `tx` to run inside the caller's transaction.
|
||||
*/
|
||||
async function recalculateRegistrationDiscount(registrationId, db = prisma) {
|
||||
const registration = await db.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
|
||||
if (!registration) return 0;
|
||||
if (registration.couponRedemptions.length === 0) {
|
||||
if (registration.discountTotal !== 0) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lines = buildCouponLines(registration);
|
||||
let total = 0;
|
||||
for (const r of registration.couponRedemptions) {
|
||||
let amount;
|
||||
if (r.coupon.type === 'fixed') {
|
||||
const inScope = scopedLines(r.coupon, lines).reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
amount = round2(Math.min(r.discountAmount, inScope));
|
||||
} else {
|
||||
amount = computeCouponDiscount(r.coupon, lines);
|
||||
}
|
||||
if (Math.abs(amount - r.discountAmount) > 0.001) {
|
||||
await db.couponRedemption.update({ where: { id: r.id }, data: { discountAmount: amount } });
|
||||
}
|
||||
total += amount;
|
||||
}
|
||||
total = round2(total);
|
||||
if (Math.abs(total - registration.discountTotal) > 0.001) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total } });
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Releases a cancelled registration's coupon uses so they can be claimed again. */
|
||||
async function releaseRegistrationCoupons(registrationId, db = prisma) {
|
||||
const released = await db.couponRedemption.updateMany({
|
||||
where: { registrationId, releasedAt: null },
|
||||
data: { releasedAt: new Date() },
|
||||
});
|
||||
if (released.count > 0) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
|
||||
}
|
||||
return released.count;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeCode,
|
||||
couponError,
|
||||
buildCouponLines,
|
||||
computeCouponDiscount,
|
||||
validateCoupon,
|
||||
describeCoupon,
|
||||
findCouponByCode,
|
||||
countCouponUses,
|
||||
previewCoupon,
|
||||
applyCouponToRegistration,
|
||||
recalculateRegistrationDiscount,
|
||||
releaseRegistrationCoupons,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Donations are never mutated once created — their remaining balance is the original
|
||||
* amount minus every "leg" (a Payment row with isDonation:false and originalPaymentId
|
||||
* pointing back at this donation) already allocated from it. A refund of the donation
|
||||
* itself also creates a leg, with a negative amount — Math.abs() so a refund reduces
|
||||
* the remaining balance (money that's left the building) instead of increasing it
|
||||
* (which a raw signed sum would do, since subtracting a negative adds).
|
||||
*
|
||||
* @param {number} originalAmount - the donation payment's own amount
|
||||
* @param {Array<{amount: number}>} legs - Payment rows with originalPaymentId === donation.id
|
||||
* @returns {number}
|
||||
*/
|
||||
function computeDonationRemaining(originalAmount, legs) {
|
||||
const alreadyUsed = (legs || []).reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
|
||||
return Number(originalAmount || 0) - alreadyUsed;
|
||||
}
|
||||
|
||||
module.exports = { computeDonationRemaining };
|
||||
@@ -49,6 +49,11 @@ function _getTransporter() {
|
||||
return { transporter: _cachedTransporter, cfg };
|
||||
}
|
||||
|
||||
/** True when a real SMTP server is configured (not the jsonTransport dev fallback). */
|
||||
function isSmtpConfigured() {
|
||||
return !!_smtpConfig().host;
|
||||
}
|
||||
|
||||
async function sendMail({ to, subject, html, text, attachments }) {
|
||||
// Never send to anonymised/deleted accounts
|
||||
if (!to || String(to).endsWith('@deleted.invalid')) {
|
||||
@@ -162,6 +167,14 @@ function fallbackLink(url) {
|
||||
</p>`;
|
||||
}
|
||||
|
||||
/** Renders a subtle, labeled secondary link — distinct from the primary CTA button. */
|
||||
function secondaryLink(label, url) {
|
||||
const color = getOrg().headerColor;
|
||||
return `<p style="text-align:center;margin:16px 0 0 0;font-size:13px;color:#64748b">
|
||||
<a href="${url}" style="color:${color}">${label}</a>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
/** Horizontal rule. */
|
||||
function divider() {
|
||||
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
|
||||
@@ -226,6 +239,28 @@ function buildPasswordResetEmail({ name, resetUrl }) {
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildTwoFactorCodeEmail({ name, code }) {
|
||||
const org = getOrg();
|
||||
const preheader = `Your ${org.name} verification code. It expires in 10 minutes.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Your verification code</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Two-factor authentication for your account</p>
|
||||
|
||||
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151">Enter this code to continue. It will expire in <strong>10 minutes</strong>.</p>
|
||||
|
||||
<p style="margin:0 0 24px 0;font-size:34px;font-weight:800;letter-spacing:8px;color:#0f172a;text-align:center;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace">${code}</p>
|
||||
|
||||
${divider()}
|
||||
<p style="margin:0;font-size:13px;color:#94a3b8">
|
||||
Never share this code with anyone. If you did not try to log in, someone may know your password —
|
||||
change it and contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
|
||||
</p>`;
|
||||
|
||||
const text = `Hi ${name || 'there'},\n\nYour ${org.name} verification code is: ${code}\n\nIt expires in 10 minutes. Never share this code with anyone.\n\nIf you did not try to log in, change your password and contact ${org.email}.\n\n${org.name} — ${org.email}`;
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildPasswordChangedEmail({ name, when, supportEmail }) {
|
||||
const org = getOrg();
|
||||
const contact = supportEmail || org.email;
|
||||
@@ -387,13 +422,16 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
|
||||
|
||||
module.exports = {
|
||||
sendMail,
|
||||
isSmtpConfigured,
|
||||
emailWrapper,
|
||||
ctaButton,
|
||||
fallbackLink,
|
||||
secondaryLink,
|
||||
divider,
|
||||
callout,
|
||||
paymentOption,
|
||||
buildPasswordResetEmail,
|
||||
buildTwoFactorCodeEmail,
|
||||
buildPasswordChangedEmail,
|
||||
buildLoginNotificationEmail,
|
||||
buildWelcomeEmail,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Post-event feedback.
|
||||
*
|
||||
* A FeedbackForm is sent once, at its sendAt, to everyone who checked in (or everyone
|
||||
* registered), with one reminder 3 days later to those who haven't answered. Each person
|
||||
* gets a personal link; only an HMAC of its token is stored, and a reminder issues a new
|
||||
* link. Anonymous forms record who has answered (so reminders skip them) but never link
|
||||
* the answers to the person.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const ANSWERABLE = ['text', 'numeric', 'date', 'yes_no', 'rating', 'multiple_choice'];
|
||||
const ALL_TYPES = [...ANSWERABLE, 'statement', 'paragraph'];
|
||||
const REMINDER_AFTER_MS = 3 * 864e5;
|
||||
|
||||
function feedbackError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`feedback:${token}`).digest('hex');
|
||||
const newToken = () => crypto.randomBytes(24).toString('hex');
|
||||
function feedbackLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/feedback/${token}`;
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Checks and tidies the questions from the form builder. Pure. */
|
||||
function cleanFields(fields) {
|
||||
if (!Array.isArray(fields)) throw feedbackError('fields must be a list');
|
||||
const out = fields.filter(f => f && String(f.label || '').trim()).map((f, i) => {
|
||||
if (!ALL_TYPES.includes(f.type)) throw feedbackError(`Question ${i + 1}: unknown type "${f.type}".`);
|
||||
const label = String(f.label).trim().slice(0, 500);
|
||||
let options = null;
|
||||
if (f.type === 'multiple_choice') {
|
||||
options = [...new Set((Array.isArray(f.options) ? f.options : []).map(o => String(o || '').trim()).filter(Boolean))].slice(0, 20);
|
||||
if (options.length < 2) throw feedbackError(`"${label}": a multiple-choice question needs at least two choices.`);
|
||||
}
|
||||
return {
|
||||
type: f.type, label, order: i, options,
|
||||
isRequired: ANSWERABLE.includes(f.type) && !!f.isRequired,
|
||||
helpText: f.helpText ? String(f.helpText).slice(0, 1000) : null,
|
||||
};
|
||||
});
|
||||
if (out.length > 50) throw feedbackError('A feedback form can have at most 50 questions.');
|
||||
if (!out.some(f => ANSWERABLE.includes(f.type))) throw feedbackError('Add at least one question.');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The stored value for one answer (null when blank); throws when it's invalid. Pure. */
|
||||
function cleanAnswer(field, raw) {
|
||||
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
||||
if (field.isRequired) throw feedbackError(`Please answer: ${field.label}`);
|
||||
return null;
|
||||
}
|
||||
const v = String(raw).trim();
|
||||
switch (field.type) {
|
||||
case 'rating': {
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 5) throw feedbackError(`"${field.label}": choose 1 to 5 stars.`);
|
||||
return String(n);
|
||||
}
|
||||
case 'multiple_choice':
|
||||
if (!(field.options || []).includes(v)) throw feedbackError(`"${field.label}": choose one of the options.`);
|
||||
return v;
|
||||
case 'yes_no':
|
||||
if (!['yes', 'no'].includes(v.toLowerCase())) throw feedbackError(`"${field.label}": answer yes or no.`);
|
||||
return v.toLowerCase();
|
||||
case 'numeric':
|
||||
if (!Number.isFinite(Number(v))) throw feedbackError(`"${field.label}": enter a number.`);
|
||||
return v;
|
||||
case 'date':
|
||||
if (isNaN(new Date(v).getTime())) throw feedbackError(`"${field.label}": enter a valid date.`);
|
||||
return v.slice(0, 10);
|
||||
default:
|
||||
return v.slice(0, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-question summary. Pure.
|
||||
* @param {Array} fields - [{ id, type, label, options }]
|
||||
* @param {Array} answers - [{ fieldId, value, name? }] (name: respondent, for named forms)
|
||||
*/
|
||||
function summarise(fields, answers) {
|
||||
const by = new Map();
|
||||
for (const a of answers) by.set(a.fieldId, [...(by.get(a.fieldId) || []), a]);
|
||||
const round1 = (n) => Math.round(n * 10) / 10;
|
||||
return fields.filter(f => ANSWERABLE.includes(f.type)).map(f => {
|
||||
const list = by.get(f.id) || [];
|
||||
const base = { fieldId: f.id, type: f.type, label: f.label, answered: list.length };
|
||||
if (f.type === 'rating') {
|
||||
const nums = list.map(a => Number(a.value)).filter(n => n >= 1 && n <= 5);
|
||||
const distribution = [1, 2, 3, 4, 5].map(star => ({ label: `${star}★`, value: nums.filter(n => n === star).length }));
|
||||
return { ...base, average: nums.length ? round1(nums.reduce((s, n) => s + n, 0) / nums.length) : null, distribution };
|
||||
}
|
||||
if (f.type === 'multiple_choice' || f.type === 'yes_no') {
|
||||
const choices = f.type === 'yes_no' ? ['yes', 'no'] : (f.options || []);
|
||||
return { ...base, counts: choices.map(c => ({ label: f.type === 'yes_no' ? (c === 'yes' ? 'Yes' : 'No') : c, value: list.filter(a => a.value === c).length })) };
|
||||
}
|
||||
if (f.type === 'numeric') {
|
||||
const nums = list.map(a => Number(a.value)).filter(Number.isFinite);
|
||||
return { ...base, average: nums.length ? round1(nums.reduce((s, n) => s + n, 0) / nums.length) : null };
|
||||
}
|
||||
return { ...base, texts: list.map(a => ({ value: a.value, ...(a.name ? { name: a.name } : {}) })) };
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Sending ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** People to ask: those who checked in (a scan on their ticket), or everyone registered. */
|
||||
async function feedbackRecipients(form) {
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: {
|
||||
eventId: form.eventId,
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } } },
|
||||
...(form.audience === 'all' ? {} : { usages: { some: {} } }),
|
||||
},
|
||||
select: { userId: true, holderUserId: true, attendee: { select: { userId: true } } },
|
||||
});
|
||||
const ids = new Set(tickets.map(t => t.holderUserId || t.attendee?.userId || t.userId));
|
||||
if (form.audience === 'all') {
|
||||
const regs = await prisma.registration.findMany({ where: { eventId: form.eventId, status: { not: 'cancelled' } }, select: { userId: true } });
|
||||
regs.forEach(r => ids.add(r.userId));
|
||||
}
|
||||
if (ids.size === 0) return [];
|
||||
return prisma.user.findMany({ where: { id: { in: [...ids] } }, select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } });
|
||||
}
|
||||
|
||||
async function sendFeedbackMessage(user, event, token, { reminder = false } = {}) {
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||||
const { waTextAny } = require('./notify');
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const link = feedbackLink(token);
|
||||
const subject = `${reminder ? 'Reminder: ' : ''}How was ${event.title}?`;
|
||||
const emailOk = !!(user.email && user.email.includes('@') && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'));
|
||||
const sent = [];
|
||||
if (emailOk) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">${reminder ? 'A quick reminder — ' : ''}How was ${event.title}?</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Thank you for coming to <strong>${event.title}</strong>. We'd love to hear what you thought — it only takes a minute.</p>
|
||||
${ctaButton('Give feedback', link)}
|
||||
${fallbackLink(link)}
|
||||
`, { preheader: `Tell us about ${event.title}` });
|
||||
try {
|
||||
await sendMail({ to: user.email, subject, html, text: `Hi ${user.name || 'there'},\n\nThank you for coming to ${event.title}. We'd love to hear what you thought: ${link}` });
|
||||
sent.push('email');
|
||||
} catch (e) {
|
||||
console.warn('[feedback] email failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
const wantsWA = ['whatsapp', 'both'].includes(user.notificationPreference) || !emailOk;
|
||||
if (wantsWA && user.phoneNumber && isValidZAPhone(user.phoneNumber)) {
|
||||
await waTextAny(user, `${reminder ? '⏰ ' : ''}Hi ${user.name || 'there'}, thank you for coming to *${event.title}*! We'd love your feedback — it only takes a minute: ${link}`);
|
||||
sent.push('WhatsApp');
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Sends the form to everyone not yet invited. Returns how many were invited. */
|
||||
async function sendFeedbackInvites(formId) {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { id: formId }, include: { event: true, invites: { select: { userId: true } } } });
|
||||
if (!form) return 0;
|
||||
const already = new Set(form.invites.map(i => i.userId));
|
||||
const people = (await feedbackRecipients(form)).filter(u => !already.has(u.id));
|
||||
for (const user of people) {
|
||||
const token = newToken();
|
||||
try {
|
||||
await prisma.feedbackInvite.create({ data: { formId: form.id, userId: user.id, tokenHash: hashToken(token) } });
|
||||
} catch (e) {
|
||||
if (e.code === 'P2002') continue; // invited by a concurrent run
|
||||
throw e;
|
||||
}
|
||||
await sendFeedbackMessage(user, form.event, token).catch(e => console.warn('[feedback] send failed:', e?.message || e));
|
||||
}
|
||||
return people.length;
|
||||
}
|
||||
|
||||
/** The single reminder: a fresh link for everyone who hasn't answered. */
|
||||
async function sendFeedbackReminders(formId) {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { id: formId }, include: { event: true } });
|
||||
const invites = await prisma.feedbackInvite.findMany({
|
||||
where: { formId, respondedAt: null, remindedAt: null },
|
||||
include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } },
|
||||
});
|
||||
for (const inv of invites) {
|
||||
const token = newToken();
|
||||
await prisma.feedbackInvite.update({ where: { id: inv.id }, data: { tokenHash: hashToken(token), remindedAt: new Date() } });
|
||||
await sendFeedbackMessage(inv.user, form.event, token, { reminder: true }).catch(e => console.warn('[feedback] reminder failed:', e?.message || e));
|
||||
}
|
||||
return invites.length;
|
||||
}
|
||||
|
||||
/** Background job: send forms whose time has come, and reminders 3 days after. */
|
||||
async function runFeedbackJob(now = new Date()) {
|
||||
let sent = 0, reminded = 0;
|
||||
const due = await prisma.feedbackForm.findMany({ where: { sentAt: null, sendAt: { lte: now } }, select: { id: true } });
|
||||
for (const f of due) {
|
||||
// Claim it first so two servers (or overlapping runs) never both send.
|
||||
const claimed = await prisma.feedbackForm.updateMany({ where: { id: f.id, sentAt: null }, data: { sentAt: now } });
|
||||
if (claimed.count === 1) sent += await sendFeedbackInvites(f.id);
|
||||
}
|
||||
const remind = await prisma.feedbackForm.findMany({ where: { reminderSentAt: null, sentAt: { lte: new Date(now.getTime() - REMINDER_AFTER_MS) } }, select: { id: true } });
|
||||
for (const f of remind) {
|
||||
const claimed = await prisma.feedbackForm.updateMany({ where: { id: f.id, reminderSentAt: null }, data: { reminderSentAt: now } });
|
||||
if (claimed.count === 1) reminded += await sendFeedbackReminders(f.id);
|
||||
}
|
||||
return { sent, reminded };
|
||||
}
|
||||
|
||||
async function findInviteByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 200) return null;
|
||||
return prisma.feedbackInvite.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: { user: { select: { id: true, name: true } }, form: { include: { event: { select: { id: true, title: true, startDate: true, endDate: true } }, fields: { orderBy: { order: 'asc' } } } } },
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ANSWERABLE, ALL_TYPES, REMINDER_AFTER_MS, feedbackError, hashToken, feedbackLink,
|
||||
cleanFields, cleanAnswer, summarise,
|
||||
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackJob, findInviteByToken,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
const { createEvent } = require('ics');
|
||||
|
||||
// Event.startDate/endDate are stored as true UTC instants (the admin event form's
|
||||
// datetime-local input is parsed in the browser's local time before being sent as an
|
||||
// ISO string), so serializing them as UTC here requires no timezone math and lets every
|
||||
// viewer's calendar app localize correctly to *their own* timezone.
|
||||
function toUtcArray(date) {
|
||||
const d = new Date(date);
|
||||
return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an RFC 5545 .ics file (as a string) for a single event.
|
||||
*
|
||||
* @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?
|
||||
* @param {string} eventUrl - absolute URL to the event's public page
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildEventIcs(event, eventUrl) {
|
||||
const { error, value } = createEvent({
|
||||
title: event.title,
|
||||
start: toUtcArray(event.startDate),
|
||||
end: toUtcArray(event.endDate),
|
||||
startInputType: 'utc',
|
||||
endInputType: 'utc',
|
||||
startOutputType: 'utc',
|
||||
endOutputType: 'utc',
|
||||
location: event.location || undefined,
|
||||
description: event.description || undefined,
|
||||
url: eventUrl,
|
||||
});
|
||||
if (error) throw error;
|
||||
return value;
|
||||
}
|
||||
|
||||
module.exports = { buildEventIcs };
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Admin "view as user" — strictly read-only.
|
||||
*
|
||||
* An impersonation token is an ordinary JWT for the target user plus an `imp` claim (the
|
||||
* ImpersonationSession id), valid for at most 30 minutes. Three layers keep it read-only:
|
||||
* 1. readOnlyGuard (mounted before every route): any request carrying an impersonation
|
||||
* token that isn't GET/HEAD/OPTIONS is refused, apart from ending the session and
|
||||
* logging page views. No controller can be reached for a write.
|
||||
* 2. protect / optionalAuth check the session is live and set req.readOnly.
|
||||
* 3. GET handlers that write as a side effect (price refresh on view) skip it when
|
||||
* req.readOnly is set.
|
||||
* The only rows an impersonation session writes are its own session and request log.
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const MAX_MINUTES = 30;
|
||||
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
||||
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
|
||||
|
||||
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
|
||||
function impersonationClaim(req) {
|
||||
const h = req.headers.authorization;
|
||||
if (!h || !h.startsWith('Bearer ')) return null;
|
||||
try {
|
||||
const decoded = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
return decoded.imp ? String(decoded.imp) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this request may go ahead while impersonating. Pure. */
|
||||
function allowedWhileImpersonating(method, path) {
|
||||
if (SAFE_METHODS.includes(String(method).toUpperCase())) return true;
|
||||
const p = String(path || '').split('?')[0].replace(/\/+$/, '');
|
||||
return method.toUpperCase() === 'POST' && ALLOWED_WRITES.includes(p);
|
||||
}
|
||||
|
||||
function logRequest(sessionId, method, path, blocked = false) {
|
||||
prisma.impersonationRequestLog.create({ data: { sessionId, method, path: String(path).slice(0, 500), blocked } }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Layer 1 — mounted in index.js before every route (webhooks never carry user tokens). */
|
||||
function readOnlyGuard(req, res, next) {
|
||||
const sessionId = impersonationClaim(req);
|
||||
if (!sessionId) return next();
|
||||
const path = req.originalUrl || req.url;
|
||||
if (!allowedWhileImpersonating(req.method, path)) {
|
||||
logRequest(sessionId, req.method, path, true);
|
||||
return res.status(403).json({ message: 'Read-only while viewing as another user. End impersonation to make changes.', code: 'IMPERSONATION_READ_ONLY' });
|
||||
}
|
||||
if (SAFE_METHODS.includes(req.method) && path.startsWith('/api/')) logRequest(sessionId, req.method, path);
|
||||
next();
|
||||
}
|
||||
|
||||
/** Layer 2 — the live session for an `imp` claim, or null when ended/expired. */
|
||||
async function liveSession(sessionId) {
|
||||
const s = await prisma.impersonationSession.findUnique({ where: { id: sessionId } });
|
||||
if (!s || s.endedAt) return null;
|
||||
if (s.expiresAt <= new Date()) {
|
||||
await prisma.impersonationSession.updateMany({ where: { id: s.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'expired' } });
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Why `admin` can't view as `target`, or null. Pure. */
|
||||
function impersonationBlocker(admin, target) {
|
||||
if (!admin || admin.role !== 'admin') return 'Only admins can view as another user.';
|
||||
if (!admin.twoFactorEnabled) return 'Turn on two-factor authentication for your account first.';
|
||||
if (!target) return 'User not found.';
|
||||
if (target.id === admin.id) return "You can't view as yourself.";
|
||||
if (target.role === 'admin') return "Admins can't be impersonated.";
|
||||
if (!target.isActive) return 'That account is deactivated.';
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { MAX_MINUTES, impersonationClaim, allowedWhileImpersonating, readOnlyGuard, liveSession, impersonationBlocker, logRequest };
|
||||
@@ -1,7 +1,8 @@
|
||||
const fs = require('fs');
|
||||
const prisma = require('../config/db');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, secondaryLink, divider, callout, paymentOption } = require('./email');
|
||||
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
|
||||
const { couponDiscountLine } = require('./pdfDocs');
|
||||
|
||||
// ─── Formatting helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,6 +23,20 @@ function fmtDateShort(d) {
|
||||
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
|
||||
// The .ics calendar-download link lives on the backend (not the frontend site), same
|
||||
// as the ticket-PDF URLs sent to WhatsApp — see whatsapp.js's BACKEND_URL usage.
|
||||
function getBackendUrl() {
|
||||
return (process.env.BACKEND_URL || '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// Only rendered when BACKEND_URL is actually configured — the .ics endpoint lives on
|
||||
// the backend, and there's no reliable way to derive that URL otherwise.
|
||||
function calendarLinkRow(eventId) {
|
||||
const backendUrl = getBackendUrl();
|
||||
if (!backendUrl || !eventId) return '';
|
||||
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
|
||||
}
|
||||
|
||||
function getOrg() {
|
||||
return {
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
@@ -75,6 +90,7 @@ async function loadRegistrationFull(registrationId) {
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
@@ -87,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 },
|
||||
@@ -101,6 +137,7 @@ async function loadPaymentFull(paymentId) {
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
@@ -112,8 +149,8 @@ async function loadPaymentFull(paymentId) {
|
||||
|
||||
// ─── Shared building blocks ───────────────────────────────────────────────────
|
||||
|
||||
/** Renders the selections summary table. */
|
||||
function selectionsTable(registrationOptions) {
|
||||
/** Renders the selections summary table, with the coupon discount (if any) as its own row. */
|
||||
function selectionsTable(registrationOptions, couponLine = null) {
|
||||
const rows = (registrationOptions || []).map(ro => {
|
||||
const name = ro.eventOption?.name || 'Option';
|
||||
const qty = ro.quantity || 1;
|
||||
@@ -127,6 +164,13 @@ function selectionsTable(registrationOptions) {
|
||||
|
||||
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
|
||||
|
||||
if (couponLine) {
|
||||
rows.push(`<tr>
|
||||
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${couponLine.label}</td>
|
||||
<td style="padding:10px 0 10px 16px;font-size:14px;color:#059669;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">-${fmtAmount(couponLine.amount)}</td>
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 4px 0">
|
||||
<tr style="background:#f8fafc">
|
||||
<th style="padding:10px 16px 10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:left;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Item</th>
|
||||
@@ -137,6 +181,14 @@ function selectionsTable(registrationOptions) {
|
||||
</table>`;
|
||||
}
|
||||
|
||||
/** Plain-text selections, one bullet per item plus the coupon discount. */
|
||||
function selectionsText(reg) {
|
||||
const lines = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`);
|
||||
const coupon = couponDiscountLine(reg);
|
||||
if (coupon) lines.push(` • ${coupon.label} — -${fmtAmount(coupon.amount)}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Renders a financial summary line. */
|
||||
function financialSummary(totalDue, totalPaid, balance) {
|
||||
const ff2 = ff;
|
||||
@@ -265,13 +317,14 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` — <strong>${eventDate}</strong>` : ''}</p>
|
||||
|
||||
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
|
||||
${selectionsTable(reg.registrationOptions)}
|
||||
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
|
||||
${accountCta(isUserActive, org.url)}`;
|
||||
${accountCta(isUserActive, org.url)}
|
||||
${calendarLinkRow(reg.eventId)}`;
|
||||
|
||||
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
|
||||
const itemsText = selectionsText(reg);
|
||||
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name} — ${org.email}\n${org.url}`;
|
||||
|
||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||
@@ -305,13 +358,14 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` The event takes place on <strong>${eventDate}</strong>.` : ''}</p>
|
||||
|
||||
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
|
||||
${selectionsTable(reg.registrationOptions)}
|
||||
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
|
||||
${accountCta(isUserActive, org.url)}`;
|
||||
${accountCta(isUserActive, org.url)}
|
||||
${calendarLinkRow(reg.eventId)}`;
|
||||
|
||||
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
|
||||
const itemsText = selectionsText(reg);
|
||||
const payText = balance > 0
|
||||
? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.`
|
||||
: formRequired
|
||||
@@ -479,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>
|
||||
@@ -487,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/>
|
||||
@@ -778,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' })}`;
|
||||
|
||||
@@ -863,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>
|
||||
@@ -885,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)']),
|
||||
@@ -1120,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');
|
||||
|
||||
@@ -1403,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 });
|
||||
}));
|
||||
@@ -1412,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,
|
||||
|
||||
@@ -93,6 +93,18 @@ function buildLineItems(registrationOptions) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
|
||||
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount comes from
|
||||
* Registration.discountTotal, which is always present.
|
||||
*/
|
||||
function couponDiscountLine(registration) {
|
||||
const amount = Number(registration?.discountTotal || 0);
|
||||
if (!(amount > 0.001)) return null;
|
||||
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
|
||||
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
|
||||
}
|
||||
|
||||
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
|
||||
function drawItemsTable(doc, { x, width, y, rows, brandColor, headerLight = true }) {
|
||||
const colDesc = x;
|
||||
@@ -198,6 +210,13 @@ async function generateReceiptPdf(payment) {
|
||||
if (rows.length > 0) {
|
||||
y = drawItemsTable(doc, { x: marginX, width: pageWidth - marginX * 2, y, rows, brandColor: org.brandColor });
|
||||
}
|
||||
const coupon = couponDiscountLine(reg);
|
||||
if (coupon) {
|
||||
y += 8;
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#059669')
|
||||
.text(`${coupon.label}: -${fmtAmount(coupon.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
|
||||
y += 14;
|
||||
}
|
||||
y += 20;
|
||||
|
||||
const boxW = 160, boxH = 32;
|
||||
@@ -240,6 +259,7 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
|
||||
const fullPriceTotal = rows.reduce((s, r) => s + (r.isEarlyBird ? r.basePrice : r.unitPrice) * r.quantity, 0);
|
||||
const chargedTotal = rows.reduce((s, r) => s + r.unitPrice * r.quantity, 0);
|
||||
const discount = Math.max(0, fullPriceTotal - chargedTotal);
|
||||
const coupon = couponDiscountLine(registration);
|
||||
|
||||
const filename = `invoice-${invoiceNo}.pdf`;
|
||||
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
|
||||
@@ -303,9 +323,10 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
|
||||
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
|
||||
y += (opts.size || 10) + 10;
|
||||
};
|
||||
if (discount > 0.01) {
|
||||
if (discount > 0.01 || coupon) {
|
||||
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
|
||||
totalLine('DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
|
||||
if (discount > 0.01) totalLine(coupon ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
|
||||
if (coupon) totalLine(coupon.label.toUpperCase(), `-${fmtAmount(coupon.amount)}`, { color: '#059669' });
|
||||
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
|
||||
y += 8;
|
||||
}
|
||||
@@ -348,4 +369,4 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
|
||||
return { filePath, filename };
|
||||
}
|
||||
|
||||
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, getBranding, docNumber };
|
||||
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, getBranding, docNumber };
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
* - computeRegistrationTotalDue: sums quantity*priceSnapshot across each RegistrationOption's
|
||||
* tranches (falling back to the legacy single-priceSnapshot/getEffectiveUnitPrice path for
|
||||
* rows created before tranches existed).
|
||||
* - Staff price: an option (or variant) can carry a staffPrice that Staff and above get
|
||||
* automatically when it's lower than the price they'd otherwise pay.
|
||||
* - Coupon discounts are stored on Registration.discountTotal (see utils/coupons.js) and
|
||||
* subtracted by computeRegistrationTotalDue, never below zero.
|
||||
* - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of
|
||||
* each RegistrationOption; if its tier has expired or lost stock, resolves the next
|
||||
* applicable tier/price for just that tranche. Older tranches — already priced-in — are
|
||||
@@ -26,6 +30,24 @@
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
/** The staff price this user gets for an option/variant, or null if none applies. */
|
||||
function staffPriceFor(option, variantId, user) {
|
||||
if (!user || !STAFF_ROLES.includes(user.role)) return null;
|
||||
const variant = variantId ? (option?.variants || []).find(v => v.id === variantId) : null;
|
||||
const price = variant && variant.staffPrice !== null && variant.staffPrice !== undefined
|
||||
? variant.staffPrice
|
||||
: option?.staffPrice;
|
||||
return price === null || price === undefined ? null : Number(price);
|
||||
}
|
||||
|
||||
// Staff price wins only when it's cheaper than the resolved (possibly early-bird) price.
|
||||
function withStaffPrice(resolved, staffPrice) {
|
||||
if (staffPrice === null || !(staffPrice < resolved.price)) return resolved;
|
||||
return { price: staffPrice, tierId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute effective unit price for an event option at a given time considering early-bird tiers.
|
||||
* Only checks deadline (not stock). Used for display and as a legacy fallback.
|
||||
@@ -65,9 +87,14 @@ function getEffectiveUnitPrice(eventOption, referenceTime, atTime) {
|
||||
*
|
||||
* @param {object} option - EventOption with price, earlyBirdTiers[]
|
||||
* @param {number} requestedQty - quantity being purchased
|
||||
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
|
||||
* @returns {Promise<{ price: number, tierId: string|null }>}
|
||||
*/
|
||||
async function resolveOptionPrice(option, requestedQty = 1) {
|
||||
async function resolveOptionPrice(option, requestedQty = 1, user = null) {
|
||||
return withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user));
|
||||
}
|
||||
|
||||
async function resolveOptionTierPrice(option, requestedQty) {
|
||||
const now = new Date();
|
||||
// Only option-level tiers (no variant association)
|
||||
const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : [])
|
||||
@@ -115,9 +142,14 @@ async function resolveOptionPrice(option, requestedQty = 1) {
|
||||
* @param {object} option - EventOption with price, earlyBirdTiers[], variants[]
|
||||
* @param {string} variantId
|
||||
* @param {number} requestedQty
|
||||
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
|
||||
* @returns {Promise<{ price: number, tierId: string|null }>}
|
||||
*/
|
||||
async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
|
||||
async function resolveVariantTierPrice(option, variantId, requestedQty = 1, user = null) {
|
||||
return withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user));
|
||||
}
|
||||
|
||||
async function resolveVariantOnlyTierPrice(option, variantId, requestedQty) {
|
||||
const now = new Date();
|
||||
const variant = (option.variants || []).find(v => v.id === variantId);
|
||||
const basePrice = (variant && variant.price !== null && variant.price !== undefined)
|
||||
@@ -172,6 +204,7 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
user: { select: { role: true } },
|
||||
}
|
||||
});
|
||||
if (!registration) return { changed: false };
|
||||
@@ -215,8 +248,8 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
|
||||
// Deadline has passed (or tier record missing) — resolve the next applicable tier
|
||||
const resolved = ro.variantId
|
||||
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity)
|
||||
: await resolveOptionPrice(ro.eventOption, newest.quantity);
|
||||
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity, registration.user)
|
||||
: await resolveOptionPrice(ro.eventOption, newest.quantity, registration.user);
|
||||
|
||||
const tierChanged = resolved.tierId !== newest.appliedTierId;
|
||||
const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001;
|
||||
@@ -273,7 +306,8 @@ function computeOptionLineTotal(ro, lastPaymentAt, atTime) {
|
||||
* Compute total due for a registration at a given time.
|
||||
*
|
||||
* Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
|
||||
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise).
|
||||
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise),
|
||||
* less the registration's coupon discount (`discountTotal`), never below zero.
|
||||
*
|
||||
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
|
||||
* and optionally payments[]
|
||||
@@ -291,6 +325,13 @@ function computeRegistrationTotalDue(registration, atTime) {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const subtotal = computeRegistrationSubtotal(registration, lastPaymentAt, atTime);
|
||||
return Math.max(0, subtotal - Number(registration.discountTotal || 0));
|
||||
}
|
||||
|
||||
/** Sum of the item lines before any coupon discount. */
|
||||
function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
|
||||
if (!registration || !Array.isArray(registration.registrationOptions)) return 0;
|
||||
return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
|
||||
}
|
||||
|
||||
@@ -305,6 +346,11 @@ function computeRegistrationTotalDue(registration, atTime) {
|
||||
*/
|
||||
function attachComputedTotals(registration) {
|
||||
if (!registration) return registration;
|
||||
// Never send which named attendees have an account: the person who typed their email or
|
||||
// phone mustn't be able to tell (the link is used server-side only).
|
||||
if (Array.isArray(registration.attendees)) {
|
||||
registration.attendees = registration.attendees.map(({ userId, ...a }) => a);
|
||||
}
|
||||
let lastPaymentAt = null;
|
||||
try {
|
||||
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
|
||||
@@ -317,6 +363,7 @@ function attachComputedTotals(registration) {
|
||||
ro.lineTotal = computeOptionLineTotal(ro, lastPaymentAt, now);
|
||||
}
|
||||
}
|
||||
registration.subtotalComputed = computeRegistrationSubtotal(registration, lastPaymentAt, now);
|
||||
registration.totalDueComputed = computeRegistrationTotalDue(registration, now);
|
||||
return registration;
|
||||
}
|
||||
@@ -332,6 +379,8 @@ module.exports = {
|
||||
refreshPricingForRegistration,
|
||||
computeOptionLineTotal,
|
||||
computeRegistrationTotalDue,
|
||||
computeRegistrationSubtotal,
|
||||
staffPriceFor,
|
||||
attachComputedTotals,
|
||||
attachComputedTotalsToList,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// Resolve a client IP from the request (works behind proxies) — mirrors the equivalent
|
||||
// local helper in userController.js, shared here for the admin-audit call sites.
|
||||
function getClientIp(req) {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
return req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
module.exports = { getClientIp };
|
||||
@@ -0,0 +1,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 };
|
||||
@@ -4,14 +4,14 @@
|
||||
* take effect without a server restart.
|
||||
* Falls back to environment variables when a key is not found in the DB.
|
||||
*
|
||||
* Encrypted keys (smtp_user, smtp_pass) are automatically decrypted on read.
|
||||
* Encrypted keys (SMTP credentials, Yoco secrets) are automatically decrypted on read.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { decrypt, isEncrypted } = require('./encryption');
|
||||
|
||||
// Keys stored encrypted in the DB — decrypted transparently on read.
|
||||
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
|
||||
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret']);
|
||||
|
||||
let _cache = {};
|
||||
let _lastFetch = 0;
|
||||
|
||||
@@ -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,291 @@
|
||||
/**
|
||||
* Ticket transfer — handing a ticket to someone else before the event.
|
||||
*
|
||||
* Privacy first: the sender only types the new holder's name and email/phone — there is
|
||||
* no user search, and nothing tells the sender whether those details belong to an
|
||||
* account. A TicketTransferRequest is created and a link is sent to the details typed;
|
||||
* nothing changes on the ticket until the new holder accepts (requestTransfer →
|
||||
* acceptTransfer). Accepting runs the transfer itself:
|
||||
*
|
||||
* - The new holder becomes the ticket's attendee (name, contact details, account). The
|
||||
* payer stays responsible for the registration; billing doesn't move.
|
||||
* - The QR code is replaced, so screenshots of the old ticket stop working. The old code
|
||||
* is kept on the TicketTransfer so scanners can say "transferred" instead of "not found".
|
||||
* - A shared (unnamed) ticket for several places has one place split off into a new
|
||||
* ticket for the new holder; the rest stay on the shared ticket and its code.
|
||||
* - The previous holder's attendee form answers are removed, and on youth events the new
|
||||
* holder needs their own parental consent.
|
||||
*/
|
||||
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const prisma = require('../config/db');
|
||||
const { validateAttendees } = require('./attendees');
|
||||
const { isMinor } = require('./consent');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
const OFFER_DAYS = 7;
|
||||
const crypto = require('crypto');
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`transfer:${token}`).digest('hex');
|
||||
function transferLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/transfer/${token}`;
|
||||
}
|
||||
|
||||
function transferError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this ticket can't be transferred by `actor`, or null when it can. Pure.
|
||||
* @param {object} t - { userId, holderUserId, usages[], event: { startDate, allowTicketTransfer }, registrationStatus }
|
||||
* @param {object} actor - { id, role }
|
||||
*/
|
||||
function transferBlocker(t, actor, now = new Date()) {
|
||||
const supervisor = SUPERVISORS.includes(actor.role);
|
||||
if (!supervisor && t.userId !== actor.id && t.holderUserId !== actor.id) return { status: 403, message: 'Only the person who booked or holds this ticket can transfer it.' };
|
||||
if (t.registrationStatus === 'cancelled') return { status: 400, message: 'This registration was cancelled.' };
|
||||
if (new Date(t.event.startDate) <= now) return { status: 400, message: 'Tickets can only be transferred before the event starts.' };
|
||||
if ((t.usages || []).length > 0) return { status: 400, message: 'This ticket has already been used (or partly used), so it can’t be transferred.' };
|
||||
if (t.event.allowTicketTransfer === false && !supervisor) return { status: 400, message: 'Ticket transfers are turned off for this event.' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfers a ticket. Returns what the caller needs to notify people afterwards.
|
||||
* @param {object} input - { ticketId, actor, name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
|
||||
*/
|
||||
async function transferTicket({ ticketId, actor, ...holder }) {
|
||||
const { findOrCreateAccount } = require('./accountMatch');
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: ticketId },
|
||||
include: {
|
||||
usages: { select: { id: true } },
|
||||
attendee: true,
|
||||
holder: { select: { id: true, name: true } },
|
||||
user: { select: { id: true, name: true } },
|
||||
event: { include: { eventOptions: true } },
|
||||
registrationOption: { include: { registration: { select: { id: true, status: true } } } },
|
||||
},
|
||||
});
|
||||
if (!ticket) throw transferError('Ticket not found', 404);
|
||||
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
|
||||
if (blocker) throw transferError(blocker.message, blocker.status);
|
||||
|
||||
const ro = ticket.registrationOption;
|
||||
const event = ticket.event;
|
||||
// Same rules as naming an attendee at registration (incl. date of birth and parent
|
||||
// details on youth events), for exactly one place.
|
||||
const [clean] = validateAttendees({ ...event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
|
||||
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
|
||||
}]);
|
||||
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
|
||||
|
||||
const { user: toUser, created } = await findOrCreateAccount({ name: clean.name, email: clean.email, phone: clean.phone });
|
||||
const currentHolderId = ticket.attendee ? ticket.attendee.userId || ticket.holderUserId : ticket.holderUserId || ticket.userId;
|
||||
if (currentHolderId && currentHolderId === toUser.id) throw transferError('This ticket is already yours.');
|
||||
|
||||
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
|
||||
const attendeeData = {
|
||||
name: clean.name, email: clean.email, phone: clean.phone, userId: toUser.id,
|
||||
dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone,
|
||||
};
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Re-check inside the transaction: a scan or another transfer may have just happened.
|
||||
const fresh = await tx.ticket.findUnique({ where: { id: ticket.id }, include: { usages: { select: { id: true } } } });
|
||||
if (!fresh || fresh.qrCode !== ticket.qrCode || fresh.usages.length > 0) throw transferError('This ticket changed while you were transferring it. Please try again.', 409);
|
||||
|
||||
let target, attendee, oldQrCode;
|
||||
if (ticket.attendeeId) {
|
||||
attendee = await tx.attendee.update({ where: { id: ticket.attendeeId }, data: attendeeData });
|
||||
// The previous holder's answers and medical details aren't the new holder's.
|
||||
await tx.formResponse.deleteMany({ where: { attendeeId: attendee.id } });
|
||||
await tx.consentRequest.updateMany({ where: { attendeeId: attendee.id }, data: { medical: null, emergency: null, medicalPurgedAt: new Date() } });
|
||||
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), holderUserId: toUser.id, emailSent: false } });
|
||||
oldQrCode = ticket.qrCode;
|
||||
} else {
|
||||
const order = await tx.attendee.count({ where: { registrationId: ro.registration.id } });
|
||||
attendee = await tx.attendee.create({
|
||||
data: { registrationId: ro.registration.id, registrationOptionId: ro.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId, order, ...attendeeData },
|
||||
});
|
||||
if ((ticket.quantity || 1) > 1) {
|
||||
// Split one place off the shared ticket; its code stays valid for the rest.
|
||||
await tx.ticket.update({ where: { id: ticket.id }, data: { quantity: ticket.quantity - 1 } });
|
||||
target = await tx.ticket.create({
|
||||
data: { qrCode: uuidv4(), registrationOptionId: ro.id, userId: ticket.userId, eventId: ticket.eventId, quantity: 1, attendeeId: attendee.id, holderUserId: toUser.id },
|
||||
});
|
||||
oldQrCode = null;
|
||||
} else {
|
||||
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), attendeeId: attendee.id, holderUserId: toUser.id, emailSent: false } });
|
||||
oldQrCode = ticket.qrCode;
|
||||
}
|
||||
}
|
||||
const transfer = await tx.ticketTransfer.create({
|
||||
data: { ticketId: target.id, fromHolderName, fromUserId: currentHolderId || null, toName: clean.name, toUserId: toUser.id, byUserId: actor.id, oldQrCode },
|
||||
});
|
||||
return { target, attendee, transfer };
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
toUser,
|
||||
accountCreated: created,
|
||||
fromHolderName,
|
||||
event,
|
||||
registrationId: ro.registration.id,
|
||||
needsConsent: !!event.requiresParentalConsent && isMinor(clean.dateOfBirth, event.startDate),
|
||||
};
|
||||
}
|
||||
|
||||
/** Checks a transfer the way transferTicket will, without touching anything. */
|
||||
async function checkTransfer({ ticketId, actor, ...holder }) {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: ticketId },
|
||||
include: {
|
||||
usages: { select: { id: true } },
|
||||
attendee: true, holder: { select: { name: true } }, user: { select: { name: true } },
|
||||
event: { include: { eventOptions: true } },
|
||||
registrationOption: { include: { eventOption: { select: { name: true } }, registration: { select: { id: true, status: true } } } },
|
||||
},
|
||||
});
|
||||
if (!ticket) throw transferError('Ticket not found', 404);
|
||||
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
|
||||
if (blocker) throw transferError(blocker.message, blocker.status);
|
||||
const ro = ticket.registrationOption;
|
||||
const [clean] = validateAttendees({ ...ticket.event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
|
||||
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
|
||||
}]);
|
||||
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
|
||||
return { ticket, clean };
|
||||
}
|
||||
|
||||
async function sendTransferOffer(request, ticket, token, fromName) {
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||||
const { waTextAny } = require('./notify');
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const link = transferLink(token);
|
||||
const event = ticket.event;
|
||||
const when = new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const subject = `${fromName} wants to give you a ticket for ${event.title}`;
|
||||
const sent = [];
|
||||
if (request.toEmail) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">You've been offered a ticket</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.toName}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${fromName} would like to give you their ticket for <strong>${event.title}</strong> on <strong>${when}</strong>. Accept it to get your own ticket and QR code.</p>
|
||||
${ctaButton('View and accept the ticket', link)}
|
||||
${fallbackLink(link)}
|
||||
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">If you weren't expecting this, you can ignore this email or decline from the link.</p>
|
||||
`, { preheader: subject });
|
||||
try {
|
||||
await sendMail({ to: request.toEmail, subject, html, text: `Hi ${request.toName},\n\n${fromName} would like to give you their ticket for ${event.title} on ${when}. View and accept it here: ${link}` });
|
||||
sent.push('email');
|
||||
} catch (e) {
|
||||
console.warn('[transfer] offer email failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
if (request.toPhone && isValidZAPhone(request.toPhone)) {
|
||||
await waTextAny({ name: request.toName, phoneNumber: request.toPhone }, `Hi ${request.toName}, ${fromName} would like to give you their ticket for *${event.title}* on ${when}. View and accept it here: ${link}`);
|
||||
sent.push('WhatsApp');
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a transfer: checks it, then sends the new holder a link to accept. Nothing on
|
||||
* the ticket changes yet, and no account is looked up.
|
||||
*/
|
||||
async function requestTransfer({ ticketId, actor, ...holder }) {
|
||||
const { ticket, clean } = await checkTransfer({ ticketId, actor, ...holder });
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
const expiresAt = new Date(Math.min(new Date(ticket.event.startDate).getTime(), Date.now() + OFFER_DAYS * 864e5));
|
||||
const request = await prisma.$transaction(async (tx) => {
|
||||
// One open offer per ticket: a new one replaces the old.
|
||||
await tx.ticketTransferRequest.updateMany({ where: { ticketId: ticket.id, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
|
||||
return tx.ticketTransferRequest.create({
|
||||
data: {
|
||||
ticketId: ticket.id, requestedById: actor.id, toName: clean.name, toEmail: clean.email, toPhone: clean.phone,
|
||||
details: { dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone },
|
||||
tokenHash: hashToken(token), expiresAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
const fromName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || actor.name || 'Someone';
|
||||
const sent = await sendTransferOffer(request, ticket, token, fromName);
|
||||
return { request, sent };
|
||||
}
|
||||
|
||||
async function findRequestByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 200) return null;
|
||||
const request = await prisma.ticketTransferRequest.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: {
|
||||
requestedBy: { select: { id: true, name: true, role: true } },
|
||||
ticket: { include: { event: { select: { id: true, title: true, startDate: true, location: true } }, registrationOption: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } }, attendee: { select: { name: true } }, user: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
if (request && request.status === 'pending' && request.expiresAt <= new Date()) {
|
||||
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'expired' } });
|
||||
request.status = 'expired';
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/** The new holder accepts: the transfer happens now, with every check run again. */
|
||||
async function acceptTransfer(token) {
|
||||
const request = await findRequestByToken(token);
|
||||
if (!request) throw transferError('This transfer link isn’t valid.', 404);
|
||||
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
|
||||
// Claim it first so a double-click can't run the transfer twice.
|
||||
const claimed = await prisma.ticketTransferRequest.updateMany({ where: { id: request.id, status: 'pending' }, data: { status: 'accepted', respondedAt: new Date() } });
|
||||
if (claimed.count !== 1) throw transferError('This transfer has already been answered.', 409);
|
||||
try {
|
||||
const d = request.details || {};
|
||||
const result = await transferTicket({
|
||||
ticketId: request.ticketId, actor: request.requestedBy,
|
||||
name: request.toName, email: request.toEmail, phone: request.toPhone,
|
||||
dateOfBirth: d.dateOfBirth, gender: d.gender, parentName: d.parentName, parentEmail: d.parentEmail, parentPhone: d.parentPhone,
|
||||
});
|
||||
return { ...result, request };
|
||||
} catch (e) {
|
||||
// Couldn't go ahead (e.g. the ticket was scanned meanwhile): the offer is void.
|
||||
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'cancelled' } }).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function declineTransfer(token) {
|
||||
const request = await findRequestByToken(token);
|
||||
if (!request) throw transferError('This transfer link isn’t valid.', 404);
|
||||
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
|
||||
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'declined', respondedAt: new Date() } });
|
||||
return request;
|
||||
}
|
||||
|
||||
/** The sender (or a supervisor) withdraws an offer that hasn't been accepted yet. */
|
||||
async function cancelTransfer(ticketId, actor) {
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId }, select: { userId: true, holderUserId: true } });
|
||||
if (!ticket) throw transferError('Ticket not found', 404);
|
||||
if (!SUPERVISORS.includes(actor.role) && ticket.userId !== actor.id && ticket.holderUserId !== actor.id) throw transferError('Not authorized', 403);
|
||||
const { count } = await prisma.ticketTransferRequest.updateMany({ where: { ticketId, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
|
||||
if (!count) throw transferError('There is no transfer waiting for this ticket.', 404);
|
||||
}
|
||||
|
||||
/** The transfer that retired this QR code, if any. */
|
||||
async function findTransferByOldCode(qrCode) {
|
||||
if (!qrCode) return null;
|
||||
return prisma.ticketTransfer.findFirst({ where: { oldQrCode: String(qrCode) }, orderBy: { createdAt: 'desc' } });
|
||||
}
|
||||
|
||||
function transferredMessage(transfer) {
|
||||
const when = new Date(transfer.createdAt).toLocaleDateString('en-ZA', { day: 'numeric', month: 'short' });
|
||||
return `This ticket was transferred to ${transfer.toName} on ${when}. The old QR code no longer works — ask for the new ticket.`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage,
|
||||
checkTransfer, requestTransfer, findRequestByToken, acceptTransfer, declineTransfer, cancelTransfer,
|
||||
};
|
||||
@@ -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,285 @@
|
||||
/**
|
||||
* Two-factor authentication helpers: 6-digit codes delivered by email or
|
||||
* WhatsApp, and "trust this device" tokens that let a browser skip the code.
|
||||
*
|
||||
* Neither the code nor the device token is stored in the clear — only an
|
||||
* HMAC keyed with JWT_SECRET, so a leaked database row can't be replayed.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { describeUserAgent } = require('./userAgent');
|
||||
|
||||
const CODE_TTL_MS = 10 * 60 * 1000; // a code is valid for 10 minutes
|
||||
const MAX_ATTEMPTS = 5; // wrong guesses allowed per code
|
||||
const RESEND_COOLDOWN_MS = 60 * 1000; // minimum gap between sends
|
||||
const CHALLENGE_MAX_AGE_MS = 30 * 60 * 1000; // after this, log in again rather than resend
|
||||
const TRUSTED_DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const METHODS = ['email', 'whatsapp'];
|
||||
|
||||
// Roles that must use two-factor authentication.
|
||||
const REQUIRED_ROLES = ['admin', 'supervisor'];
|
||||
|
||||
// Application error carrying the HTTP status the controller should respond with.
|
||||
function fail(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
function generateCode() {
|
||||
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
||||
}
|
||||
|
||||
function hmac(value) {
|
||||
return crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(String(value)).digest('hex');
|
||||
}
|
||||
|
||||
// Namespaced so a code hash can never collide with a device-token hash.
|
||||
const hashCode = (code) => hmac(`2fa-code:${code}`);
|
||||
const hashDeviceToken = (token) => hmac(`2fa-device:${token}`);
|
||||
|
||||
function safeEqual(a, b) {
|
||||
const ba = Buffer.from(String(a));
|
||||
const bb = Buffer.from(String(b));
|
||||
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
function hasRealEmail(user) {
|
||||
return !!(user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@deleted.local'));
|
||||
}
|
||||
|
||||
function maskEmail(email) {
|
||||
const [local, domain] = String(email || '').split('@');
|
||||
if (!local || !domain) return '•••';
|
||||
return `${local[0]}•••@${domain}`;
|
||||
}
|
||||
|
||||
function maskPhone(phone) {
|
||||
const digits = String(phone || '').replace(/\D/g, '');
|
||||
return `••• ••• ${digits.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Channels this user could receive a code on right now. */
|
||||
async function availableMethods(user) {
|
||||
const { isValidZAPhone, isConfigured } = require('./whatsapp');
|
||||
const methods = [];
|
||||
if (hasRealEmail(user)) methods.push('email');
|
||||
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) methods.push('whatsapp');
|
||||
return methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this user's role must use two-factor authentication.
|
||||
* TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off (break-glass).
|
||||
*/
|
||||
function requiresTwoFactor(user) {
|
||||
if (String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() === 'false') return false;
|
||||
return REQUIRED_ROLES.includes(user?.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a required-2FA user should be enrolled on, or null when no channel
|
||||
* can actually deliver a code — SMTP not set up and no WhatsApp — in which case the
|
||||
* requirement is skipped rather than locking the account out.
|
||||
*/
|
||||
async function enforcementMethod(user) {
|
||||
const { isSmtpConfigured } = require('./email');
|
||||
if (hasRealEmail(user) && isSmtpConfigured()) return 'email';
|
||||
const { isValidZAPhone, isConfigured } = require('./whatsapp');
|
||||
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) return 'whatsapp';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when this user must enrol in 2FA before using their account. */
|
||||
async function mustEnrolTwoFactor(user) {
|
||||
if (!user || user.twoFactorEnabled || !requiresTwoFactor(user)) return false;
|
||||
return (await enforcementMethod(user)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the code and reports where it actually went. A WhatsApp failure (session
|
||||
* disconnected, not configured) falls back to email so a user is never locked out
|
||||
* by the WhatsApp gateway being down. Throws when no channel could deliver.
|
||||
* Pass `allowFallback: false` when the point is to prove the chosen channel works.
|
||||
*/
|
||||
async function deliverCode(user, method, code, { allowFallback = true } = {}) {
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.info(`[2fa][dev] code for ${user.email}: ${code}`);
|
||||
}
|
||||
|
||||
if (method === 'whatsapp') {
|
||||
const { isValidZAPhone, isConfigured, sendText } = require('./whatsapp');
|
||||
const { buildWATwoFactorCode } = require('./waMessages');
|
||||
try {
|
||||
// sendText silently no-ops when WhatsApp isn't set up, so check first.
|
||||
if (!isValidZAPhone(user.phoneNumber) || !(await isConfigured())) throw new Error('WhatsApp unavailable');
|
||||
await sendText(user.phoneNumber, buildWATwoFactorCode({ name: user.name, code }));
|
||||
return { method: 'whatsapp', destination: maskPhone(user.phoneNumber) };
|
||||
} catch (e) {
|
||||
console.warn('[2fa] WhatsApp code failed:', e?.message || e);
|
||||
if (!allowFallback) throw fail('We couldn\'t send a WhatsApp message right now. Please try again later or choose email.', 503);
|
||||
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
const { sendMail, buildTwoFactorCodeEmail } = require('./email');
|
||||
try {
|
||||
await sendMail({ to: user.email, subject: `Your ${orgName} verification code`, ...buildTwoFactorCodeEmail({ name: user.name, code }) });
|
||||
} catch (e) {
|
||||
console.warn('[2fa] Email code failed:', e?.message || e);
|
||||
throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
}
|
||||
return { method: 'email', destination: maskEmail(user.email) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a fresh code and records the challenge. Any earlier challenge of the same
|
||||
* purpose for this user is discarded, so only the newest code works.
|
||||
*/
|
||||
async function createChallenge(user, purpose, method, options) {
|
||||
const code = generateCode();
|
||||
const sent = await deliverCode(user, method, code, options);
|
||||
|
||||
await prisma.twoFactorChallenge.deleteMany({ where: { userId: user.id, purpose } });
|
||||
const challenge = await prisma.twoFactorChallenge.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
purpose,
|
||||
method: sent.method,
|
||||
codeHash: hashCode(code),
|
||||
expiresAt: new Date(Date.now() + CODE_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
return { challengeId: challenge.id, method: sent.method, destination: sent.destination };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a submitted code. Returns the challenge (with its user) on success and
|
||||
* throws otherwise. Pass `userId` to also require the challenge belong to that user.
|
||||
*/
|
||||
async function verifyChallenge(challengeId, code, purpose, userId) {
|
||||
const invalid = () => fail('That code is invalid or has expired. Please request a new one.');
|
||||
if (!challengeId || typeof challengeId !== 'string') throw invalid();
|
||||
const submitted = String(code ?? '').replace(/\D/g, '');
|
||||
|
||||
// Count the attempt before comparing, atomically, so parallel guesses can't
|
||||
// slip past the attempt limit.
|
||||
const counted = await prisma.twoFactorChallenge.updateMany({
|
||||
where: {
|
||||
id: challengeId,
|
||||
purpose,
|
||||
...(userId ? { userId } : {}),
|
||||
usedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
attempts: { lt: MAX_ATTEMPTS },
|
||||
},
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
if (counted.count !== 1) throw invalid();
|
||||
|
||||
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
|
||||
if (!challenge || !safeEqual(challenge.codeHash, hashCode(submitted))) {
|
||||
const left = challenge ? MAX_ATTEMPTS - challenge.attempts : 0;
|
||||
throw fail(left > 0 ? `Incorrect code. ${left} attempt${left === 1 ? '' : 's'} left.` : 'Too many incorrect attempts. Please request a new code.');
|
||||
}
|
||||
|
||||
// Single use — if two requests race with the right code, only one wins.
|
||||
const used = await prisma.twoFactorChallenge.updateMany({
|
||||
where: { id: challengeId, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (used.count !== 1) throw invalid();
|
||||
|
||||
return challenge;
|
||||
}
|
||||
|
||||
/** Replaces the code on an existing challenge and sends it again. */
|
||||
async function resendChallenge(challengeId, purpose, userId) {
|
||||
const expired = () => fail('This verification has expired. Please start again.');
|
||||
if (!challengeId || typeof challengeId !== 'string') throw expired();
|
||||
|
||||
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
|
||||
if (!challenge || challenge.purpose !== purpose || challenge.usedAt || (userId && challenge.userId !== userId)) throw expired();
|
||||
if (Date.now() - challenge.createdAt.getTime() > CHALLENGE_MAX_AGE_MS) throw expired();
|
||||
|
||||
const wait = RESEND_COOLDOWN_MS - (Date.now() - challenge.lastSentAt.getTime());
|
||||
if (wait > 0) throw fail(`Please wait ${Math.ceil(wait / 1000)} seconds before requesting another code.`, 429);
|
||||
|
||||
// Claim the send slot first so two simultaneous resends can't both go out.
|
||||
const claimed = await prisma.twoFactorChallenge.updateMany({
|
||||
where: { id: challengeId, lastSentAt: challenge.lastSentAt, usedAt: null },
|
||||
data: { lastSentAt: new Date() },
|
||||
});
|
||||
if (claimed.count !== 1) throw fail('Please wait before requesting another code.', 429);
|
||||
|
||||
const code = generateCode();
|
||||
const sent = await deliverCode(challenge.user, challenge.method, code);
|
||||
await prisma.twoFactorChallenge.update({
|
||||
where: { id: challengeId },
|
||||
data: { codeHash: hashCode(code), method: sent.method, attempts: 0, expiresAt: new Date(Date.now() + CODE_TTL_MS) },
|
||||
});
|
||||
|
||||
return { challengeId, method: sent.method, destination: sent.destination };
|
||||
}
|
||||
|
||||
/** Remembers this browser for 30 days. Returns the raw token to hand to the client. */
|
||||
async function issueTrustedDevice(userId, userAgent) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
await prisma.trustedDevice.deleteMany({ where: { userId, expiresAt: { lt: new Date() } } });
|
||||
await prisma.trustedDevice.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: hashDeviceToken(token),
|
||||
device: describeUserAgent(userAgent),
|
||||
expiresAt: new Date(Date.now() + TRUSTED_DEVICE_TTL_MS),
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/** True when `token` is an unexpired trusted-device token belonging to this user. */
|
||||
async function isTrustedDevice(userId, token) {
|
||||
if (!token || typeof token !== 'string') return false;
|
||||
const row = await prisma.trustedDevice.findUnique({ where: { tokenHash: hashDeviceToken(token) } });
|
||||
if (!row || row.userId !== userId || row.expiresAt <= new Date()) return false;
|
||||
prisma.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Forgets every trusted device — call whenever credentials or sessions are reset. */
|
||||
async function clearTrustedDevices(userId) {
|
||||
try {
|
||||
await prisma.trustedDevice.deleteMany({ where: { userId } });
|
||||
} catch (e) {
|
||||
console.error('Failed to clear trusted devices:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
METHODS,
|
||||
REQUIRED_ROLES,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
mustEnrolTwoFactor,
|
||||
MAX_ATTEMPTS,
|
||||
generateCode,
|
||||
hashCode,
|
||||
hashDeviceToken,
|
||||
maskEmail,
|
||||
maskPhone,
|
||||
hasRealEmail,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
};
|
||||
@@ -47,9 +47,12 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
|
||||
: `Your registration for *${eventTitle}* has been updated.`;
|
||||
|
||||
const { computeOptionLineTotal } = require('./pricing');
|
||||
const items = (reg.registrationOptions || [])
|
||||
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`)
|
||||
.join('\n');
|
||||
const { couponDiscountLine } = require('./pdfDocs');
|
||||
const coupon = couponDiscountLine(reg);
|
||||
const items = [
|
||||
...(reg.registrationOptions || []).map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`),
|
||||
...(coupon ? [`- ${coupon.label} — -${fmtAmount(coupon.amount)}`] : []),
|
||||
].join('\n');
|
||||
|
||||
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const due = totalDue ?? 0;
|
||||
@@ -149,6 +152,25 @@ function buildWALogin({ name, when, location, userAgent }) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Two-factor code ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildWATwoFactorCode({ name, code }) {
|
||||
const org = getOrg();
|
||||
return [
|
||||
`🔑 *Your verification code*`,
|
||||
'',
|
||||
`Hi ${name || 'there'},`,
|
||||
'',
|
||||
`Your *${org.name}* verification code is:`,
|
||||
'',
|
||||
`*${code}*`,
|
||||
'',
|
||||
`_It expires in 10 minutes. Never share this code with anyone._`,
|
||||
'',
|
||||
`> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Welcome ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildWAWelcome({ name, events }) {
|
||||
@@ -306,6 +328,7 @@ module.exports = {
|
||||
buildWADonationAppliedToRegistrant,
|
||||
buildWADonationUnassignedFromRegistrant,
|
||||
buildWALogin,
|
||||
buildWATwoFactorCode,
|
||||
buildWAWelcome,
|
||||
buildWAAccountClosed,
|
||||
buildWATicketCaption,
|
||||
|
||||
@@ -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,134 @@
|
||||
/**
|
||||
* Yoco configuration and API helpers.
|
||||
*
|
||||
* The secret key and webhook secret are stored AES-256-GCM encrypted in AppSetting
|
||||
* (yoco_secret_key, yoco_webhook_secret) and managed from Admin → Site Settings → Yoco.
|
||||
* YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET in .env are only used as fallbacks when the
|
||||
* database has no value, so older deployments keep working until an admin saves them.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const prisma = require('../config/db');
|
||||
const { getSetting } = require('./settingsCache');
|
||||
|
||||
const YOCO_API = 'https://payments.yoco.com/api';
|
||||
|
||||
const KEY_SECRET = 'yoco_secret_key';
|
||||
const KEY_WEBHOOK = 'yoco_webhook_secret';
|
||||
const KEY_WEBHOOK_ID = 'yoco_webhook_id';
|
||||
|
||||
const getYocoSecretKey = () => getSetting(KEY_SECRET, process.env.YOCO_SECRET_KEY);
|
||||
const getYocoWebhookSecret = () => getSetting(KEY_WEBHOOK, process.env.YOCO_WEBHOOK_SECRET);
|
||||
|
||||
/** Where each value currently comes from: 'db' | 'env' | 'none'. */
|
||||
async function getYocoConfigSource() {
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: [KEY_SECRET, KEY_WEBHOOK] } } });
|
||||
const inDb = new Set(rows.filter(r => r.value).map(r => r.key));
|
||||
const source = (key, envVar) => (inDb.has(key) ? 'db' : process.env[envVar] ? 'env' : 'none');
|
||||
return {
|
||||
secretKey: source(KEY_SECRET, 'YOCO_SECRET_KEY'),
|
||||
webhookSecret: source(KEY_WEBHOOK, 'YOCO_WEBHOOK_SECRET'),
|
||||
};
|
||||
}
|
||||
|
||||
const KNOWN_PREFIXES = ['sk_test_', 'sk_live_', 'whsec_'];
|
||||
|
||||
/**
|
||||
* Mask a secret like a card number: keep the prefix and the last 3 characters.
|
||||
* maskSecret('sk_test_abcdefghijklmnaba') → 'sk_test_**************aba'
|
||||
*/
|
||||
function maskSecret(value) {
|
||||
if (!value) return '';
|
||||
const prefix = KNOWN_PREFIXES.find(p => value.startsWith(p)) || value.slice(0, 4);
|
||||
const rest = value.slice(prefix.length);
|
||||
if (rest.length <= 6) return prefix + '*'.repeat(rest.length);
|
||||
return prefix + '*'.repeat(rest.length - 3) + rest.slice(-3);
|
||||
}
|
||||
|
||||
function yocoMode(key) {
|
||||
if (!key) return 'unknown';
|
||||
if (key.startsWith('sk_test_')) return 'test';
|
||||
if (key.startsWith('sk_live_')) return 'live';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function notConfiguredError() {
|
||||
const err = new Error('Online payments are not configured. An admin needs to add the Yoco secret key in Site Settings → Yoco.');
|
||||
err.statusCode = 503;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** `Bearer <key>` for Yoco API calls; throws a 503-style error when no key is set. */
|
||||
async function yocoAuthHeader() {
|
||||
const key = await getYocoSecretKey();
|
||||
if (!key) throw notConfiguredError();
|
||||
return `Bearer ${key}`;
|
||||
}
|
||||
|
||||
/** Turn a Yoco API error into a message an admin can act on. */
|
||||
function friendlyYocoError(e) {
|
||||
const status = e.response?.status;
|
||||
const detail = e.response?.data?.message || e.response?.data?.description || e.response?.data?.errorMessage;
|
||||
let message;
|
||||
if (status === 401) message = 'Yoco rejected the secret key. Check that it was copied correctly.';
|
||||
else if (status === 403) message = 'This Yoco account is not allowed to manage webhooks.';
|
||||
else if (status === 400) message = `Yoco rejected the request${detail ? `: ${detail}` : '.'}`;
|
||||
else if (status) message = `Yoco returned an error (${status})${detail ? `: ${detail}` : '.'}`;
|
||||
else message = e.message || 'Could not reach Yoco.';
|
||||
const err = new Error(message);
|
||||
err.statusCode = e.statusCode || (status === 401 || status === 403 || status === 400 ? 400 : 502);
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Call the Yoco API. `key` overrides the stored key (used to verify a key before saving it). */
|
||||
async function yocoRequest(method, path, body, key) {
|
||||
const auth = key ? `Bearer ${key}` : await yocoAuthHeader();
|
||||
try {
|
||||
const res = await axios({
|
||||
method,
|
||||
url: `${YOCO_API}${path}`,
|
||||
data: body,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: auth },
|
||||
timeout: 15000,
|
||||
});
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
throw friendlyYocoError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function listWebhooks(key) {
|
||||
const data = await yocoRequest('get', '/webhooks', undefined, key);
|
||||
return data?.subscriptions || [];
|
||||
}
|
||||
|
||||
const registerWebhook = (name, url) => yocoRequest('post', '/webhooks', { name, url });
|
||||
const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComponent(id)}`);
|
||||
|
||||
/**
|
||||
* The URL Yoco should post events to: BACKEND_URL, or the public origin of the
|
||||
* current request (correct behind a proxy when TRUST_PROXY is set).
|
||||
*/
|
||||
function defaultWebhookUrl(req) {
|
||||
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
|
||||
return base ? `${base}/api/webhooks/yoco` : '';
|
||||
}
|
||||
|
||||
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
|
||||
function webhookUrlProblem(url) {
|
||||
let u;
|
||||
try { u = new URL(url); } catch { return 'Enter a full webhook URL, e.g. https://api.example.com/api/webhooks/yoco'; }
|
||||
if (u.protocol !== 'https:') return 'The webhook URL must use https:// so Yoco can reach it.';
|
||||
if (['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname)) {
|
||||
return 'The webhook URL must be a public address, not localhost.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KEY_SECRET, KEY_WEBHOOK, KEY_WEBHOOK_ID,
|
||||
getYocoSecretKey, getYocoWebhookSecret, getYocoConfigSource,
|
||||
maskSecret, yocoMode, yocoAuthHeader,
|
||||
yocoRequest, listWebhooks, registerWebhook, deleteWebhook,
|
||||
defaultWebhookUrl, webhookUrlProblem,
|
||||
};
|
||||
@@ -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,70 @@
|
||||
const { deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport } = require('../src/utils/analytics');
|
||||
|
||||
const ev = (sessionId, stage, day, extra = {}) => ({ sessionId, stage, createdAt: `${day}T10:00:00Z`, ...extra });
|
||||
|
||||
describe('deviceFromUA', () => {
|
||||
test('mobile, tablet, desktop and bots', () => {
|
||||
expect(deviceFromUA('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile/15E148')).toBe('mobile');
|
||||
expect(deviceFromUA('Mozilla/5.0 (Linux; Android 14; SM-X200) Safari')).toBe('tablet');
|
||||
expect(deviceFromUA('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120')).toBe('desktop');
|
||||
expect(deviceFromUA('Googlebot/2.1')).toBeNull();
|
||||
expect(deviceFromUA('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanSource', () => {
|
||||
test('tidies, defaulting to direct', () => {
|
||||
expect(cleanSource(' Facebook ')).toBe('facebook');
|
||||
expect(cleanSource('camp-2025<script>')).toBe('camp-2025script');
|
||||
expect(cleanSource('')).toBe('direct');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionsFrom', () => {
|
||||
test('a session counts as having reached every earlier stage', () => {
|
||||
const [s] = sessionsFrom([ev('a', 'start', '2026-01-02')]);
|
||||
expect(s.reach).toBe(1);
|
||||
expect(s.days.view).toBe('2026-01-02');
|
||||
});
|
||||
|
||||
test('paid only counts for sessions that submitted', () => {
|
||||
const out = sessionsFrom([ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'submit', '2026-01-03'), ev('b', 'view', '2026-01-01')], new Set(['a', 'b']));
|
||||
const a = out.find(s => s.sessionId === 'a');
|
||||
expect(a.reach).toBe(3);
|
||||
expect(a.days.paid).toBe('2026-01-03');
|
||||
expect(a.source).toBe('fb');
|
||||
expect(out.find(s => s.sessionId === 'b').reach).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('funnelReport', () => {
|
||||
const sessions = sessionsFrom([
|
||||
ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'start', '2026-01-01'), ev('a', 'submit', '2026-01-01'),
|
||||
ev('b', 'view', '2026-01-01', { source: 'fb', device: 'desktop' }), ev('b', 'start', '2026-01-02'),
|
||||
ev('c', 'view', '2026-01-02', { device: 'mobile' }),
|
||||
ev('d', 'view', '2026-01-02', { device: 'mobile' }),
|
||||
], new Set(['a']));
|
||||
|
||||
test('counts and conversion per stage', () => {
|
||||
const r = funnelReport(sessions);
|
||||
expect(r.totals).toEqual({ view: 4, start: 2, submit: 1, paid: 1 });
|
||||
expect(r.conversion.start).toBe(50);
|
||||
expect(r.conversion.paid).toBe(25);
|
||||
expect(r.conversion.startToSubmit).toBe(50);
|
||||
});
|
||||
|
||||
test('daily trend and breakdowns', () => {
|
||||
const r = funnelReport(sessions);
|
||||
expect(r.trend).toEqual([{ day: '2026-01-01', view: 2, start: 1, submit: 1, paid: 1 }, { day: '2026-01-02', view: 2, start: 1, submit: 0, paid: 0 }]);
|
||||
expect(r.bySource.find(s => s.key === 'fb')).toMatchObject({ view: 2, paid: 1, conversion: 50 });
|
||||
expect(r.bySource.find(s => s.key === 'direct').view).toBe(2);
|
||||
expect(r.byDevice.find(s => s.key === 'mobile').view).toBe(3);
|
||||
});
|
||||
|
||||
test('rolled-up rows add to the raw counts', () => {
|
||||
const daily = rollupRows(sessionsFrom([ev('x', 'view', '2025-10-01'), ev('x', 'submit', '2025-10-01')], new Set(['x'])));
|
||||
expect(daily.find(r => r.stage === 'paid').sessions).toBe(1);
|
||||
const r = funnelReport(sessions, daily);
|
||||
expect(r.totals).toEqual({ view: 5, start: 3, submit: 2, paid: 2 });
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user