Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bddb329e3 | ||
|
|
be4b6f02ea | ||
|
|
880e3206cc | ||
|
|
7b2bfc31e6 | ||
|
|
963c1824bf | ||
|
|
38262022ef | ||
|
|
15b73d5d9e | ||
|
|
c74a943709 | ||
|
|
28c8fe9715 | ||
|
|
2e7bf263ee | ||
|
|
377630473d | ||
|
|
286cf09dd8 | ||
|
|
27c870d46d | ||
|
|
a0c5a6a98c | ||
|
|
e7969429b6 | ||
|
|
4a492a8b16 | ||
|
|
294fff0b41 | ||
|
|
f33d04a57c | ||
|
|
6b186b3481 | ||
|
|
7ba40163db | ||
|
|
428b438251 | ||
|
|
6ce59af0ea | ||
|
|
53ed72d032 | ||
|
|
35d02c243b | ||
|
|
e2e6ec0e97 | ||
|
|
92ab8065ef | ||
|
|
084e3c6679 | ||
|
|
de196e34f2 | ||
|
|
763b66ff56 | ||
|
|
8f92a8c3e9 | ||
|
|
6fc6c35214 | ||
|
|
be1d6f2e31 | ||
|
|
dc02f6b9ff | ||
|
|
103c328a6a | ||
|
|
7446388bfa | ||
|
|
8a4c144f6d | ||
|
|
3a68b24a0f | ||
|
|
9be78c3c34 | ||
|
|
d268236686 | ||
|
|
95a8bd0796 | ||
|
|
101632e758 | ||
|
|
7cf5f79d01 | ||
|
|
00464c086d | ||
|
|
4e07034bbd | ||
|
|
2d6c840614 | ||
|
|
235da5bf61 | ||
|
|
f6581eb441 | ||
|
|
0a8788afff | ||
|
|
b560d0d340 | ||
|
|
b2439a781e | ||
|
|
545f3d913e | ||
|
|
619b5b9a93 | ||
|
|
958a5a5950 | ||
|
|
f2be3a8b45 | ||
|
|
ce22f44f7c | ||
|
|
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 |
+342
@@ -7,6 +7,348 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.31.1] - 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- Settings → Organisation has a "Google Search Console verification" field. Paste the HTML tag from Search Console and the site outputs it on every page, so each deployment can verify its own domain without uploading a file.
|
||||
|
||||
## [1.31.0] - 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- The registration funnel's traffic source works without tagged links: visits from Facebook, Instagram, Google, X, LinkedIn and other sites are recognised from where the visitor came from (and Facebook's/Google's click ids). Links without a tag used to count as "direct". WhatsApp still only shows from tagged links (`?utm_source=whatsapp`), since it doesn't pass that on.
|
||||
- The event page's Share button shares the link tagged `?utm_source=share`, and Save QR encodes it tagged `?utm_source=qr`, so those visits show separately.
|
||||
- Event pages tell search engines about the event in Google's event format (schema.org `Event`): name, dates, venue, picture, organiser, ticket prices in Rand and whether it's sold out. Google can then show events with their date, venue and ticket link in search results and in its event listings. Contact-only events are listed without tickets.
|
||||
|
||||
### Changed
|
||||
|
||||
- Event pages, the events list and the home page tell search engines their one official address, so `?utm_source=…`, `?src=…` and alias links all count towards the same page in search rankings. Shared event links also carry the event's address.
|
||||
|
||||
## [1.30.1] - 2026-10-22
|
||||
|
||||
### Changed
|
||||
|
||||
- All tickets now share the design of the tickets printed from the user dashboard: rounded white cards with a light border and soft shadow, the date beside the event name, and the QR code in the middle. This covers the PDF sent by email and WhatsApp (now 6 per page, so the QR codes are bigger) and printing from Event Tickets (staff) and At the door (supervisor). Printed names are now escaped, so a name with `<` or `&` prints correctly.
|
||||
|
||||
## [1.30.0] - 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- The register form asks for an optional **Donation amount** under the ticket options. Pick a suggested amount (worked out from the ticket total), enter a custom amount (min R15), or choose None. The donation shows in the total and is paid in the same Yoco checkout as the tickets. It's recorded as a separate donation to the event, so it never counts towards the registration's balance.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Event pages with a waitlist crashed with "Attempted to call soldOutLines() from the server". The sold-out helper now lives in `lib/waitlist.ts` so the server page can call it.
|
||||
|
||||
## [1.29.1] - 2026-10-21
|
||||
|
||||
### Changed
|
||||
|
||||
- The Coupon codes page matches the Bundle deals page and works on phones. Codes show as cards with the discount, events, uses, restrictions, dates and limits. On phones, the edit form and the list of uses open full-width from the bottom, and the discount type is picked from three buttons.
|
||||
- The coupons list hides codes that are off, expired or used up unless you tick "Show codes that are off, expired or used up". Search also matches event names.
|
||||
- When creating or editing a code, the Events list shows upcoming events only, soonest first, with a search box and a "Show past events" option. Past events already on the code stay listed so they can be unticked.
|
||||
|
||||
## [1.29.0] - 2026-10-21
|
||||
|
||||
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
|
||||
|
||||
### Added
|
||||
|
||||
- Bundle deals now come in four types, chosen when you create one:
|
||||
- **Exact set**: these tickets in these numbers for one price, e.g. 2 adults + 3 kids for R220. This is how bundles worked before.
|
||||
- **Pick any**: any mix of the qualifying tickets for a set price, e.g. any 4 of Adult or Child for R250, so 2 + 2 and 4 + 0 both count.
|
||||
- **Buy X get Y free**: in every set of qualifying tickets the cheapest are free, e.g. buy 3, get 1 free.
|
||||
- **Group discount**: from a group size up, every qualifying ticket costs less (a price per ticket or a percentage off), e.g. 10+ tickets: R70 each. It applies once, to the whole group, and never makes a ticket dearer.
|
||||
- The deals list and the "Complete bundle" cards on the register form, at the door, manual registration and the kiosk describe each type in one line, e.g. "Any 4 of Adult, Child for R250". For a group discount the button reads "Complete group" and adds the tickets still needed.
|
||||
- `npm run set-demo`: Family Fun Day also has a pick-any deal, "Lunch for four" (any 4 lunch packs for R220).
|
||||
|
||||
## [1.28.3] - 2026-10-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bundle deals edit form: each ticket row's quantity box filled the whole row, which pushed the ticket picker off the side and added a sideways scrollbar. The quantity box is narrow again, with the ticket picker beside it.
|
||||
|
||||
## [1.28.2] - 2026-10-21
|
||||
|
||||
### Changed
|
||||
|
||||
- The Bundle deals page has a new layout that works on phones. It shows upcoming events only, soonest first, with a "Show past events" option. Bundles show as cards with their tickets, price, saving, uses and dates. On a phone, the edit form opens full-width from the bottom, and its price section shows the standard price, the bundle price and the saving side by side.
|
||||
|
||||
## [1.28.1] - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- At the door, manual registration and the self-service kiosk now list the event's bundle deals and show the same bundle cards as the register form: once a ticket from a bundle is picked, **Complete bundle** tops up the ticket numbers, and **+** adds another full set.
|
||||
|
||||
### Fixed
|
||||
|
||||
- At the door now has the same "← Back to dashboard" link as the other dashboard pages.
|
||||
- At the door's "less than already paid" warning shows the total after bundle deals.
|
||||
|
||||
## [1.28.0] - 2026-10-21
|
||||
|
||||
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
|
||||
|
||||
### Added
|
||||
|
||||
- **Bundle deals**: sell a set of tickets together for one price, e.g. 5 Adult + 3 Child for R500 instead of R550. Supervisors set them up per event under Dashboard → **Bundle deals**: the tickets and quantities (one variant or any variant), the price, optional dates, and on/off.
|
||||
- Bundles apply automatically, with no code, as many times as a registration's tickets allow; leftover tickets are charged as usual. A bundle only applies when it's cheaper than the tickets at the prices actually charged, early-bird and staff prices included. When several compete, the one that saves the most is used first. This works for online registration, manual registration, at the door and on the kiosk.
|
||||
- The event page and the register form list the event's deals, and every basket shows the saving live. On the register form, once a ticket from a bundle is picked, the bundle appears with what's missing and a **Complete bundle** button that tops up the ticket numbers. Once it's complete, **+** adds another full set. Invoices, receipts, emails, WhatsApp messages and the Yoco payment page show it as its own line, e.g. "Bundle: Family deal ×2".
|
||||
- The Discounts report lists bundle deals next to coupon codes. Each bundle shows how often it's been used and the total saving given.
|
||||
- `npm run set-demo`: Family Fun Day sells a **Family deal** bundle (2 adults + 3 kids for R220) in place of the old Family pass ticket.
|
||||
|
||||
### Changed
|
||||
|
||||
- Coupon codes now work out after bundle deals, on what's left. Registration.discountTotal now holds bundle and coupon discounts together, with the bundle part in the new `bundleDiscount`.
|
||||
- Removing a code from a registration keeps its bundle deal. When an early-bird tier expires before payment and prices change, bundle deals are worked out again and coupon amounts are kept.
|
||||
- Registrations keep a bundle they already have, even after it's turned off or its dates pass. Deleting a bundle that has been used turns it off instead.
|
||||
|
||||
## [1.27.1] - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- `npm run set-demo` also stages ten completed events from the past year, with door sales recorded by staff, staff cash counts, check-ins, refunds, donations and post-event feedback. Their cashups go through every path: a quick close; full closes with card fees, a cash deposit fee, a written-off shortfall, cash kept on hand and an unaccounted amount; a close, reopen and re-close; pending EFT money that was later received and some still outstanding; and one event left open with the cash counted, ready for a live cashup. Funnel stats older than 90 days are stored as daily totals, the way the app rolls them up. See `backend/DEMO_DATA.md`.
|
||||
|
||||
## [1.27.0] - 2026-10-21
|
||||
|
||||
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
|
||||
|
||||
### Added
|
||||
|
||||
- Cashups now record where each payment method's money ended up. For cash, card, EFT and other, the expected takings are split into what's **in the bank**, **cash on hand**, and **pending** (on the card machine or Yoco report, or with a proof of payment), with a reference for the pending amount. Card fees, bank charges, cash shortfalls and other losses are **written off** with a reason. Each method shows what's still **unaccounted**. Closing with an unaccounted amount is allowed after a warning, and the amount comes off profit, as variance did before.
|
||||
- When staff have counted the cash, cash on hand is worked out for you: the count, less what reached the bank and any deposit fee written off as a bank charge.
|
||||
- Pending money on a closed event can be marked **received** or **written off** later from the cashup screen, without reopening the event. Each one is logged with who did it and when.
|
||||
- Reopening an event starts the next cashup from the last one, with any pending money settled since folded in.
|
||||
- Write-offs show as **Bank & card charges** on the cashup screen and in the Cashup, Finance and Profit reports. The Cashup report has new In bank, On hand, Pending, Written off and Unaccounted columns.
|
||||
|
||||
### Changed
|
||||
|
||||
- Net profit now subtracts write-offs as their own line instead of showing card fees and bank charges as missing income. The net figure is the same.
|
||||
- Every dashboard page uses the same back link: the small "← Back to dashboard" link above the page title, as on the Waitlist page. The grey Back buttons are gone. Site settings, profile, donate and manual registration, which had no way back, now have one too.
|
||||
|
||||
## [1.26.1] - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- `npm run set-demo` in the backend replaces the database contents with demo data for showing the platform to other churches. It keeps the site settings and the admin account, and stages six upcoming events with registrations, payments, tickets, a waitlist, camp cabins, coupons, costs and funnel stats. See `backend/DEMO_DATA.md`.
|
||||
|
||||
## [1.26.0] - 2026-10-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded Prisma from 5.22 to 7.10. Prisma 7 connects through the `@prisma/adapter-pg` driver adapter (node-postgres) instead of the Rust query engine. The database URL moved from `schema.prisma` to the new `backend/prisma.config.js`, which loads `backend/.env` itself. All Prisma clients are now created in `src/config/db.js`, and the server shares one client instead of opening two connection pools. No schema or migration changes.
|
||||
- Prisma 7 checks TLS certificates on database connections. A `DATABASE_URL` that uses SSL with a self-signed or otherwise invalid certificate now fails to connect where Prisma 5 accepted it.
|
||||
- `npm run dev` in the backend now uses Node's built-in `node --watch` instead of `nodemon`.
|
||||
- The frontend now passes ESLint with no errors or warnings, down from 1,137, and `next build` runs ESLint again. It was skipped before (`eslint.ignoreDuringBuilds`), which is how the issues built up.
|
||||
- API data is now typed. `src/types` describes the records the backend returns, modelled on the Prisma schema, and `apiFetch` returns `unknown` unless the caller names a type, so untyped responses no longer pass silently.
|
||||
- Effects list everything they depend on. Data loaders are wrapped in `useCallback`, so a page reloads when its inputs change instead of showing stale data.
|
||||
- The pricing helpers that were copied into several pages now live in `src/lib/pricing.ts`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Internal links on the home page, the 404 page and "Forgot your password?" now navigate client-side instead of reloading the whole page.
|
||||
- Changing the page size on Admin → Users fetched the list twice; it now fetches once.
|
||||
- At the door, the refund history now shows a refund's reason. The page was reading a field that doesn't exist, so the reason never appeared.
|
||||
- In the Master Orders report, a ticket option named like one of the fixed columns (e.g. "name") could overwrite that column's value.
|
||||
- Removed code that could never run: an unreachable "Pay with Yoco" button on the registration form, an unused attendee-forms block on the registration success page, unused export helpers in Reports, and fonts that were downloaded but never applied.
|
||||
|
||||
### Security
|
||||
|
||||
- Backend `npm audit` now reports 0 vulnerabilities:
|
||||
- Patched `follow-redirects` (pulled in by `axios`) to 1.16.1, which fixes leaking credential headers on cross-origin redirects.
|
||||
- Removed `nodemon`, the only backend package that pulled in `braces`.
|
||||
- Jest's coverage loader now uses `js-yaml` 4, which drops the `sprintf-js` advisory.
|
||||
- Frontend: overrode `postcss-selector-parser` to 7.1.7, used by the Tailwind 3 build. The generated CSS is byte-identical. The remaining frontend findings (7 high) are all the same `braces` advisory (GHSA-vfj7-8cjw-p6xm). It has no patched release, comes in only through build and lint tools (Tailwind 3 and `eslint-config-next`), and only ever handles glob patterns from our own config, never user input.
|
||||
|
||||
## [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
|
||||
|
||||
+80
-4
@@ -164,13 +164,54 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
## Payments, donations & refunds
|
||||
|
||||
- **Online card payments via Yoco** — checkout for the full outstanding balance,
|
||||
confirmed by a signed webhook.
|
||||
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
|
||||
@@ -200,6 +241,13 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were
|
||||
checked in and how many remain.
|
||||
- **Scan stats** — recent scans and per-event scan counts.
|
||||
- **Offline scanning** — while online, a staff member locks an event section (or the
|
||||
whole event) to their device and downloads its tickets. They can then keep scanning
|
||||
with no connection; scans are queued on the device and sync automatically when it's
|
||||
back online (never redeeming more than is left). Only one device can hold a section,
|
||||
and while it's locked nobody else can scan it, online or offline. Problems found
|
||||
while syncing go into a report for a supervisor. Supervisors can force-release a
|
||||
lost device. The scanner can be added to the home screen (PWA) and opens offline.
|
||||
- **Printing** — A4 sheets (8 tickets per page) and A6 single tickets.
|
||||
- **Resend** — send tickets to a different email/phone at the door.
|
||||
|
||||
@@ -259,6 +307,18 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
- Registration detail: itemised bill (early-bird lines grouped), payments,
|
||||
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
|
||||
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
|
||||
- **Ticket transfer** — offer a ticket to someone else before the event by typing
|
||||
their name and email/phone (no user search, and nothing reveals whether they have an
|
||||
account). They get a link to accept or decline; the ticket only moves once they
|
||||
accept, and the sender can cancel until then (offers expire after 7 days or at the
|
||||
event start). On accepting they get a new ticket and QR code, and an inactive account
|
||||
with an activation link if they're new. The old code stops working, and scanning it says it
|
||||
was transferred, both online and in offline-mode sync reports. One place can be split
|
||||
off a ticket covering several people. Billing stays with the payer. It's not allowed
|
||||
once a ticket has been scanned, and it can be turned off per event (supervisors can
|
||||
always transfer, from At the door → Tickets). On youth events the new holder needs
|
||||
their own parental consent, and the previous holder's form answers are removed.
|
||||
Every transfer is in the audit log.
|
||||
- **Payment history** (`/dashboard/user/payments`) — paginated, with filters by
|
||||
date, method and payment/refund.
|
||||
- **Donate** page.
|
||||
@@ -299,6 +359,19 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
Reconcile (Yoco transactions), and Payment Link.
|
||||
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
|
||||
discount codes, and see who used each one.
|
||||
- **Rooms & groups** (`/dashboard/supervisor/allocations`):
|
||||
- Per event, allocation sets such as "Rooms", "Small groups" or "Buses". Each set
|
||||
has groups with an optional capacity, gender restriction and leader (staff).
|
||||
- A drag-and-drop board, plus auto-allocate: fill evenly, respecting capacity and
|
||||
gender, keeping bookings together or splitting them.
|
||||
- Printable per-group and master lists. On youth events they can include medical
|
||||
details, and viewing those is audited.
|
||||
- The group can show on tickets and in My Events.
|
||||
- Email and WhatsApp attendees can be filtered to one group.
|
||||
- Leaders see their people under Staff → **My groups**.
|
||||
- Works on named attendees. Events can ask each person's gender.
|
||||
- **Offline scanning** (`/dashboard/supervisor/offline-scans`) — devices holding
|
||||
offline locks (with force release) and sync reports to review.
|
||||
- **Waitlist** (`/dashboard/supervisor/waitlist`) — the queue per event: offer a
|
||||
place now, move people up or down, or remove them.
|
||||
- **Parental consent** (`/dashboard/supervisor/consent`) — consent status per
|
||||
@@ -357,9 +430,9 @@ a viewer with its own filters, stat tiles and bar charts.
|
||||
| Category | Reports |
|
||||
|----------|---------|
|
||||
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
|
||||
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list |
|
||||
| Registration | Registration funnel (views → started → submitted → paid, daily trend, by source / device / ticket option) · Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list · Feedback summary (response rate, average ratings, choice breakdowns, comments; compare events) |
|
||||
| Ticketing | Ticket usage summary |
|
||||
| Donations | Donations breakdown (used / unused per event) |
|
||||
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
|
||||
| Cashup | Cashup reconciliation · Cashup audit trail |
|
||||
|
||||
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
|
||||
@@ -384,6 +457,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
|
||||
| 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.
|
||||
@@ -430,6 +504,8 @@ falling back to a general guide.
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
|
||||
| Daily event summary email | 07:00 daily |
|
||||
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
|
||||
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) |
|
||||
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) |
|
||||
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
|
||||
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
|
||||
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
|
||||
@@ -469,5 +545,5 @@ falling back to a general guide.
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+11
@@ -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,7 @@ or Branding tab has been saved even once — the wizard always writes
|
||||
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
|
||||
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
|
||||
| `notify_user_on_impersonation` | `"false"` | When `"true"`, a user is emailed (with the reason) whenever an admin views their account with Impersonate. Set under Site Settings → Legal → Support access. |
|
||||
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
|
||||
|
||||
@@ -33,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.)
|
||||
@@ -72,6 +74,7 @@ BACKUP_ENABLED=true
|
||||
# - 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,
|
||||
|
||||
@@ -21,6 +21,13 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Coupons](#coupons)
|
||||
- [Parental consent](#parental-consent)
|
||||
- [Waitlist](#waitlist)
|
||||
- [Offline scanning](#offline-scanning)
|
||||
- [Ticket transfer](#ticket-transfer)
|
||||
- [Rooms & groups](#rooms--groups)
|
||||
- [Sponsor a spot](#sponsor-a-spot)
|
||||
- [Post-event feedback](#post-event-feedback)
|
||||
- [Registration funnel](#registration-funnel)
|
||||
- [Impersonation](#impersonation)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -278,6 +285,143 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
|
||||
---
|
||||
|
||||
## Impersonation
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/admin/impersonate/:userId` | admin (2FA on) | `{ reason }` → `{ token, session }`. The token acts as the user, is read-only, and lasts 30 minutes. Refused for admins and inactive users |
|
||||
| POST | `/api/admin/impersonate/end` | impersonation token | End the session |
|
||||
| POST | `/api/admin/impersonate/log` | impersonation token | `{ path }` — page viewed (audit trail) |
|
||||
| GET | `/api/admin/impersonate/current` | impersonation token | The live session |
|
||||
| GET | `/api/admin/impersonation-sessions` | admin | Sessions with their request trail |
|
||||
|
||||
Read-only enforcement:
|
||||
- A guard mounted before every route returns `403 { code: "IMPERSONATION_READ_ONLY" }` for any non-GET/HEAD/OPTIONS request made with an impersonation token. The only exceptions are `end` and `log`. Blocked attempts are logged.
|
||||
- An ended or expired session gets `401 { code: "IMPERSONATION_ENDED" }`.
|
||||
- GET handlers skip side-effect writes (pricing refresh) while `req.readOnly` is set.
|
||||
|
||||
## Registration funnel
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/analytics/track` | public (rate-limited) | `{ sessionId, eventId, stage: 'view'\|'start', source? }` → always `204`. Bots are ignored; device type comes from the User-Agent |
|
||||
| GET | `/api/analytics/funnel?eventIds=&from=&to=` | supervisor+ | `{ totals, conversion, trend, bySource, byDevice, byOption, perEvent }` |
|
||||
|
||||
How the stages are recorded:
|
||||
- `POST /api/registrations` accepts `analyticsSessionId`. It's stored on the registration and records the `submit` stage.
|
||||
- `paid` is read from that registration's status.
|
||||
- Counts are sessions that reached a stage or a later one.
|
||||
|
||||
Data handling:
|
||||
- Sessions inactive for 90 days are rolled up into `AnalyticsDaily` by a daily job (`ANALYTICS_ROLLUP_ENABLED`).
|
||||
- `/api/stats/overview` now includes `funnel: { views, paid, conversion }` for live events over the past 30 days.
|
||||
|
||||
## Post-event feedback
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/feedback/event/:eventId` | supervisor+ | The event's feedback form (with `invited` / `responses` counts), or null |
|
||||
| PUT | `/api/feedback/event/:eventId` | supervisor+ | `{ sendAt, audience: 'checked_in'\|'all', anonymous, intro, fields[] }` — fields can't change once answered (409) |
|
||||
| DELETE | `/api/feedback/event/:eventId` | supervisor+ | Remove the form |
|
||||
| POST | `/api/feedback/event/:eventId/send-now` | supervisor+ | Send now (also invites anyone not yet invited) |
|
||||
| GET / POST | `/api/feedback/public/:token` | link holder | Load the form / submit `{ answers: { fieldId: value } }` once |
|
||||
| GET | `/api/feedback/summary?eventIds=a,b` | supervisor+ | Per event: invited, responses, response rate, and per question: rating average + distribution, choice counts, text answers (names only when not anonymous) |
|
||||
|
||||
Notes:
|
||||
- New field types `rating` (answers `"1"`–`"5"`) and `multiple_choice` (`options`: array of strings). They can be used on attendee forms too.
|
||||
- Only HMACs of link tokens are stored, and a reminder issues a new link.
|
||||
- A job every 15 minutes (`FEEDBACK_JOB_ENABLED`, `FEEDBACK_JOB_INTERVAL_MS`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
|
||||
|
||||
## Sponsor a spot
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/sponsorships/event/:eventId` | public | `{ enabled, open, spotPrice, showProgress, goalSpots, spotsFunded, sponsors }` — never recipients |
|
||||
| POST | `/api/sponsorships/checkout` | public (optional login) | `{ eventId, spots? \| amount?, anonymous?, sponsorName?, message?, name?, email? }` → Yoco `redirectUrl`. Guests give a name and email |
|
||||
| GET | `/api/sponsorships/event/:eventId/pool` | supervisor+ | `{ received, assigned, remaining, sponsors, spotsFunded, spotPrice, goalSpots }` |
|
||||
|
||||
How the money is handled:
|
||||
- A spot costs the cheapest paid main ticket.
|
||||
- Yoco checkout metadata carries `type: 'sponsorship'`. The webhook records the payment as `isDonation` + `isSponsorship` against the event, together with `sponsorName`, `sponsorMessage` and `sponsorAnonymous`.
|
||||
- Pool funds are assigned with `PUT /api/payments/assign-donation`.
|
||||
|
||||
Other changes:
|
||||
- Event fields: `allowSponsorships`, `sponsorshipGoalSpots`, `showSponsorshipProgress`.
|
||||
- `POST /api/events/:id/email-attendees` accepts `filter.audience: 'sponsors'`.
|
||||
- `POST /api/automations/schedule` jobs accept `audience: 'sponsors'`.
|
||||
|
||||
## Rooms & groups
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/allocations/event/:eventId` | supervisor+ | Board: sets (groups, assignments), named attendees, possible leaders, unnamed places |
|
||||
| POST | `/api/allocations/sets` | supervisor+ | `{ eventId, name, showOnTicket? }` |
|
||||
| PUT / DELETE | `/api/allocations/sets/:id` | supervisor+ | Rename / show on tickets; delete |
|
||||
| POST | `/api/allocations/sets/:id/groups` | supervisor+ | `{ name, capacity?, gender? ('male'\|'female'), leaderUserId? }` |
|
||||
| PUT / DELETE | `/api/allocations/groups/:id` | supervisor+ | Edit (can't drop below current members or break gender); delete (members become unassigned) |
|
||||
| PUT | `/api/allocations/sets/:id/assignments` | supervisor+ | `{ attendeeId, groupId }` (null unassigns); enforces capacity and gender |
|
||||
| DELETE | `/api/allocations/sets/:id/assignments` | supervisor+ | Clear the set |
|
||||
| POST | `/api/allocations/sets/:id/auto` | supervisor+ | `{ mode: 'together' \| 'split' }` — places everyone not yet placed |
|
||||
| GET | `/api/allocations/sets/:id/lists?medical=true` | supervisor+ | Groups with members for printing (medical view is audited) |
|
||||
| GET | `/api/allocations/my-groups` | staff+ | Groups the caller leads (medical details for supervisors only) |
|
||||
|
||||
Related changes:
|
||||
- `Event.collectGender` makes a gender (`male` / `female`) required for each named attendee.
|
||||
- Email and WhatsApp attendees accept `filter.allocationGroupId`.
|
||||
|
||||
## Ticket transfer
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }` → `201`. Creates an offer and sends a link to the details given. **Nothing changes on the ticket yet.** A new offer replaces an open one |
|
||||
| DELETE | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | Withdraw the open offer |
|
||||
| GET | `/api/tickets/transfer-offer/:token` | link holder | `{ status, toName, fromName (first name), event, option, expiresAt }` |
|
||||
| POST | `/api/tickets/transfer-offer/:token/accept` | link holder | Accept — the transfer happens now (all checks run again) |
|
||||
| POST | `/api/tickets/transfer-offer/:token/decline` | link holder | Decline |
|
||||
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
|
||||
|
||||
Privacy:
|
||||
- There is no user search. The sender's request is checked (rules, required fields) without looking up any account, and no response reveals whether the email/phone belongs to an account.
|
||||
- Offers expire after 7 days or when the event starts.
|
||||
- Only an HMAC of the link token is stored.
|
||||
|
||||
Rules:
|
||||
- Only before the event starts, and only for tickets that have never been scanned.
|
||||
- `Event.allowTicketTransfer` turns it off for non-supervisors.
|
||||
|
||||
What a transfer does:
|
||||
- The new holder becomes the ticket's attendee and gets a new QR code. A shared ticket for several places has one place split off into its own ticket.
|
||||
- An account is matched by email/phone, or created inactive with an activation link.
|
||||
- The previous holder's attendee form answers are deleted, and the medical details on their consent are cleared. A minor on a youth event gets a new consent request.
|
||||
|
||||
Old QR codes:
|
||||
- `scan-preview` and `scan` return `410 { code: "TICKET_TRANSFERRED" }`.
|
||||
- Offline sync reports them as `transferred`.
|
||||
- Each transfer is audited as `ticket_transferred`.
|
||||
|
||||
## Offline scanning
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/scan-locks?eventId=` | staff+ | Active offline locks for an event |
|
||||
| POST | `/api/scan-locks` | staff+ | Lock `{ eventId, sectionId?, deviceId, deviceName? }` to this device (409 if the scope is held elsewhere) |
|
||||
| GET | `/api/scan-locks/:id/snapshot?deviceId=` | staff+ | Tickets valid in the lock's scope (lock holder only) |
|
||||
| POST | `/api/scan-locks/:id/sync` | staff+ | `{ deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }` — idempotent; returns accepted, duplicates, conflicts |
|
||||
| DELETE | `/api/scan-locks/:id` | staff+ | Release (`{ deviceId }`, lock holder only) |
|
||||
| POST | `/api/scan-locks/:id/force-release` | supervisor+ | Release a lost device's lock |
|
||||
| GET | `/api/scan-locks/reports?eventId=&unreviewed=` | supervisor+ | Sync reports |
|
||||
| POST | `/api/scan-locks/reports/:id/review` | supervisor+ | Mark a report reviewed |
|
||||
|
||||
How locks work:
|
||||
- One device per scope. A whole-event lock (`sectionId` null) clashes with every section lock on that event.
|
||||
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and `deviceId`. It enforces the section on the server, and returns `423 { code: "SCAN_LOCKED" }` when another device holds a lock covering the ticket.
|
||||
|
||||
How syncing works:
|
||||
- Synced scans never redeem more than is left on a ticket.
|
||||
- Conflicts are recorded in the sync report: `not_found`, `cancelled`, `wrong_scope`, `already_used`, `partly_used` and `consent`.
|
||||
- Check-in messages are sent after the sync.
|
||||
- Locks, releases, force-releases and syncs are written to the admin audit log.
|
||||
|
||||
## Waitlist
|
||||
|
||||
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
|
||||
@@ -562,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.
|
||||
|
||||
@@ -574,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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Demo data
|
||||
|
||||
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
|
||||
for showing the platform to other churches. It stages six upcoming events with
|
||||
registrations, payments, tickets and people, and ten completed events from the past year
|
||||
with door sales, check-ins, feedback and cashups. That way every dashboard, report,
|
||||
check-in and cashup screen has something to show.
|
||||
|
||||
> **This deletes real data.** Run it only against a database you're happy to wipe, such
|
||||
> as a dedicated demo deployment. It is not something to run on a live church site.
|
||||
|
||||
---
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run set-demo # shows the database it will wipe and asks you to type "demo"
|
||||
npm run set-demo -- --yes # no prompt (for scripts)
|
||||
```
|
||||
|
||||
The script uses `DATABASE_URL` from `backend/.env`. To point it at another database
|
||||
for one run, set it in the environment first. A value already in the environment wins
|
||||
over `.env`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL="postgresql://user:pass@host:5432/hope_demo" npm run set-demo
|
||||
```
|
||||
|
||||
It takes about five seconds and is safe to re-run as often as you like. Each run wipes the
|
||||
previous demo and creates it again.
|
||||
|
||||
### Before you run it
|
||||
|
||||
- The admin account must already exist. By default that's `joshua@crosscode.co.za`.
|
||||
To keep a different account, set `DEMO_ADMIN_EMAIL`:
|
||||
```bash
|
||||
DEMO_ADMIN_EMAIL="admin@example.org" npm run set-demo
|
||||
```
|
||||
If the account isn't found, the script stops before changing anything.
|
||||
- Migrations must be up to date (`npm run prisma:deploy`).
|
||||
|
||||
---
|
||||
|
||||
## What it keeps and what it clears
|
||||
|
||||
| Kept | Cleared |
|
||||
|---|---|
|
||||
| Site settings (`AppSetting`): org name, branding, SMTP, Yoco, WhatsApp, legal pages | Every event, with its options, variants, early-bird tiers, sections, forms, costs and cash-ups |
|
||||
| The admin account, including its login history, trusted devices and 2FA | Every other user |
|
||||
| Migration history | Registrations, payments, tickets, scans, coupons, waitlists, consents, cabins, feedback, analytics, audit log |
|
||||
| Uploaded files on disk (`public/uploads`) | Queued scheduled emails/WhatsApps (`data/scheduled-emails.json`) |
|
||||
|
||||
The wipe and the new data are written in one database transaction. If that fails,
|
||||
nothing changes. The past events' cashups run straight afterwards, through the same
|
||||
code as the cashup screen. If one of those fails, re-run the script.
|
||||
|
||||
---
|
||||
|
||||
## What it creates
|
||||
|
||||
### Upcoming events
|
||||
|
||||
All dates are worked out from the day you run it, so these events are always upcoming.
|
||||
Registrations are spread over the past month, as if sign-ups have been coming in.
|
||||
The data is generated from a fixed seed, so every run looks the same.
|
||||
|
||||
| When (from today) | Event | Shows off |
|
||||
|---|---|---|
|
||||
| ~1½ weeks, Sat 07:30 | **Men's Breakfast: Built to Last** | **Sold out** (60 places), 5 people on the **waitlist**, card / cash / EFT payments recorded by staff, 2 cancelled registrations |
|
||||
| ~2½ weeks, Sat 10:00 | **Family Fun Day** | Adult and child tickets with a **bundle deal** (Family deal: 2 adults + 3 kids for R220 instead of R280, used by about a third of registrations) and a **pick-any** deal (Lunch for four: any 4 lunch packs for R220), a non-ticket **add-on** (lunch pack), a fixed-amount **coupon** (`FUNDAY20`), entry and food-stall check-in sections |
|
||||
| ~5 weeks, Fri–Sat | **Rooted Women's Conference** | Two **early-bird tiers** (one already expired, one still open) plus a Saturday-only ticket, T-shirt **variants** (S–XXL), a **required sign-up form** (dietary needs, workshop choice, prayer requests), a 20% **group coupon** (`ROOTED20`) used by 4 registrations |
|
||||
| ~7 weeks, Mon–Thu | **Ignite Summer Camp** | **Named attendees** (campers aged 12–17), signed **parental consent**, gender collected, **deposits and part-payments**, campers placed in boys' and girls' **cabins** with staff leaders, **sponsor-a-spot** donations towards a 10-spot goal |
|
||||
| ~8 weeks, Sun 19:00 | **Carols by Candlelight** | A **free event** with group tickets (1–6 people), plus general **donations** |
|
||||
| ~14 weeks, Fri–Sun | **Marriage Enrichment Weekend** | Room-type **variants with limited stock**, early-bird prices per room type, a per-person spa add-on, R1 000 EFT deposits |
|
||||
|
||||
Each event also gets:
|
||||
|
||||
- A cover photo (from Unsplash, which `next.config` already allows) and a contact person.
|
||||
- **Costs**, both once-off and per item, so the profit view and cash-up have figures.
|
||||
- About 30 days of **funnel analytics** (views → started → submitted → paid) by source
|
||||
(WhatsApp, Facebook, Instagram, email, direct) and device.
|
||||
|
||||
Paid registrations have tickets with QR codes, the same way the app issues them once
|
||||
payment is complete. Part-paid and pending ones don't have tickets yet.
|
||||
|
||||
### Past events
|
||||
|
||||
Ten completed events spread over the past year, for showing cashups and the historical
|
||||
reports. Each one has:
|
||||
|
||||
- Online sales in the five weeks before the event, with a few unpaid no-shows.
|
||||
- **Door sales** on the day, recorded by the staff team (cash and card machine).
|
||||
- **Check-ins**: about 90% of tickets scanned, with the odd group arriving short.
|
||||
- **Staff cash counts** with note and coin denominations.
|
||||
|
||||
Some events also have refunds, donations and post-event feedback.
|
||||
|
||||
The cashups cover every path through the cashup screen:
|
||||
|
||||
| When | Event | Cashup to demo |
|
||||
|---|---|---|
|
||||
| ~11 months ago | **Church Family Picnic** | **Quick closed** (no settlement) |
|
||||
| 24 Dec | **Christmas Eve Candlelight Service** | Free event with card and cash **donations**. Card settled net of **Yoco fees**, collection banked |
|
||||
| Easter Sunday | **Easter Sunrise Service & Breakfast** | Cash banked less a **cash deposit fee** (bank charge), card fees, a **refund** |
|
||||
| Mother's Day | **Mother's Day Brunch** | **Closed with R50 cash short → reopened → recounted → closed again.** The history shows all three steps |
|
||||
| ~3½ months ago | **Youth Winter Camp** | Deposits and balances. **EFT pending** (POPs) at close, later **marked received** without reopening |
|
||||
| ~2½ months ago | **Men's Golf Day** | Card-heavy, card fees written off, a refund |
|
||||
| ~2 months ago | **Kids Holiday Club** | Cash-heavy door sales. **R20 cash shortfall written off**, **R500 kept on hand** as float |
|
||||
| ~6 weeks ago | **Women's High Tea** | Closed with **R30 unaccounted**, shown in red |
|
||||
| ~4 weeks ago | **Missions Fundraiser Dinner** | **R2 600 EFT still pending** (POP received, not cleared). Shows "Mark received" / "Write off" |
|
||||
| ~10 days ago | **Worship Night** | **Still open, with the cash already counted.** Use it to do a cashup live in the demo |
|
||||
|
||||
Easter, the Youth Camp, the Holiday Club and the High Tea have **feedback forms** with
|
||||
ratings and comments. Funnel stats older than 90 days are stored as daily totals, the
|
||||
way the app rolls them up overnight, so the funnel report covers the whole year.
|
||||
|
||||
### Totals
|
||||
|
||||
A typical run gives roughly:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Users | 130 (plus the admin) |
|
||||
| Registrations | ~700 (paid, part-paid, pending, cancelled and refunded) |
|
||||
| Payments | ~640, including donations, sponsorships and refunds |
|
||||
| Tickets | ~780, with ~490 check-ins |
|
||||
| Named campers | ~45 |
|
||||
| Feedback responses | ~80 |
|
||||
| Funnel rows | ~4 400 |
|
||||
|
||||
---
|
||||
|
||||
## Logging in
|
||||
|
||||
Every demo account uses the password **`Demo@1234`**.
|
||||
|
||||
| Role | Email | Good for showing |
|
||||
|---|---|---|
|
||||
| Supervisor | `lerato.khumalo@example.com` | Event setup, reports, coupons, viewing cashups |
|
||||
| Staff | `sipho.dlamini@example.com` | At-the-door payments and check-in scanning |
|
||||
| Staff | `anri.botha@example.com` | |
|
||||
| Staff | `daniel.pillay@example.com` | |
|
||||
|
||||
Closing, reopening and settling pending money in a cashup are admin-only, so do the
|
||||
live Worship Night cashup as your own admin account.
|
||||
|
||||
The other 126 people are ordinary members (role `user`), with emails like
|
||||
`thandi.mokoena@example.com`. Log in as one of them to show the member view: their
|
||||
registrations, tickets and payments. Any registrant's email from an event's attendee
|
||||
list works.
|
||||
|
||||
Your own admin account keeps its existing password.
|
||||
|
||||
---
|
||||
|
||||
## Safety
|
||||
|
||||
- **Nothing is sent.** The script writes straight to the database and never calls the
|
||||
email, WhatsApp or Yoco code.
|
||||
- **Nothing can reach a real person later.** Demo people use `@example.com` addresses
|
||||
(a reserved domain that can't receive mail) and have **no phone numbers**, so
|
||||
reminders or broadcasts sent during a demo go nowhere. All parental consents are
|
||||
already signed, so no consent reminders go out.
|
||||
- Card payments have made-up Yoco ids (`demo_…`). They don't exist in Yoco, so leave
|
||||
the demo site's Yoco settings in test mode or empty.
|
||||
|
||||
---
|
||||
|
||||
## Customising
|
||||
|
||||
Everything lives in [`scripts/set-demo.js`](scripts/set-demo.js). There is one
|
||||
`build…()` function per event (`buildMensBreakfast`, `buildYouthCamp`, …), and
|
||||
`main()` decides which ones run. To change an event's name, prices, ticket types or
|
||||
number of registrations, edit its function. To add an event, write a new `build…()`
|
||||
and add it to the list in `main()`. `addBundle(event, { name, price }, [[option, quantity], …])`
|
||||
adds a bundle deal. Registrations added after it get the deal automatically, worked out
|
||||
the same way as the app does it.
|
||||
|
||||
The past events are in `buildPastEvents()`, one spec each: dates, ticket types, costs,
|
||||
how many people bought online and at the door, and a `cashup` that says how it was
|
||||
closed. Supported closes:
|
||||
- `'quick'`
|
||||
- `'full'`, with `lines` saying where the money ended up
|
||||
- `'reclose'`
|
||||
- `'none'`, which leaves the event open
|
||||
|
||||
A full close can add `later` to settle pending money after closing. The helpers
|
||||
`cardLine`, `eftLine` and `cashLine` build the usual settlements: card net of Yoco fees,
|
||||
EFT with an optional pending POP, and cash banked with an optional float, deposit fee or
|
||||
shortfall.
|
||||
+21
-4
@@ -79,8 +79,8 @@ 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`) |
|
||||
@@ -99,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).
|
||||
|
||||
---
|
||||
|
||||
@@ -141,6 +143,10 @@ npm run prisma:status
|
||||
npm run prisma:generate
|
||||
```
|
||||
|
||||
### Demo data
|
||||
|
||||
`npm run set-demo` wipes the database (keeping site settings and the admin account) and stages six upcoming demo events with registrations, payments and tickets. See [DEMO_DATA.md](DEMO_DATA.md).
|
||||
|
||||
---
|
||||
|
||||
## Running the Server
|
||||
@@ -333,6 +339,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 |
|
||||
|--------|------|------|-------------|
|
||||
@@ -500,6 +515,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 }`.
|
||||
@@ -524,6 +541,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
|
||||
Generated
+1666
-372
File diff suppressed because it is too large
Load Diff
+14
-10
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.31.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"test": "jest",
|
||||
"postinstall": "prisma generate",
|
||||
"prisma:generate": "prisma generate",
|
||||
@@ -13,10 +13,12 @@
|
||||
"prisma:status": "prisma migrate status",
|
||||
"prisma:reset": "prisma migrate reset --force",
|
||||
"migrate:env-to-db": "node scripts/migrate-env-to-db.js",
|
||||
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run"
|
||||
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run",
|
||||
"set-demo": "node scripts/set-demo.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"@sentry/node": "^10.71.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -37,16 +39,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.4.2",
|
||||
"nodemon": "^3.0.1",
|
||||
"prisma": "^5.4.2"
|
||||
"prisma": "^7.10.1"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1"
|
||||
"uuid": "^11.1.1",
|
||||
"@istanbuljs/load-nyc-config": {
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"follow-redirects": "^1.16.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@5.22.0": true,
|
||||
"@prisma/client@5.22.0": true,
|
||||
"@prisma/engines@5.22.0": true,
|
||||
"prisma@7.10.1": true,
|
||||
"@prisma/engines@7.10.1": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"@parcel/watcher": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Prisma CLI config (migrate, generate, studio). Prisma 7 no longer reads the
|
||||
// datasource URL from schema.prisma or auto-loads .env, so both happen here.
|
||||
require('dotenv/config');
|
||||
const { defineConfig } = require('prisma/config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
// process.env rather than env() so `prisma generate` still works on a
|
||||
// machine with no DATABASE_URL (generate never connects).
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CashupWriteOffCategory" AS ENUM ('card_fees', 'bank_charges', 'cash_shortfall', 'other');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventCashupLine" ADD COLUMN "bankAmount" DOUBLE PRECISION,
|
||||
ADD COLUMN "onHandAmount" DOUBLE PRECISION,
|
||||
ADD COLUMN "pendingAmount" DOUBLE PRECISION,
|
||||
ADD COLUMN "pendingReference" TEXT,
|
||||
ADD COLUMN "writeOffTotal" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventCashupWriteOff" (
|
||||
"id" TEXT NOT NULL,
|
||||
"lineId" TEXT NOT NULL,
|
||||
"category" "CashupWriteOffCategory" NOT NULL,
|
||||
"amount" DOUBLE PRECISION NOT NULL,
|
||||
"reason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "EventCashupWriteOff_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventCashupPendingResolution" (
|
||||
"id" TEXT NOT NULL,
|
||||
"lineId" TEXT NOT NULL,
|
||||
"outcome" TEXT NOT NULL,
|
||||
"amount" DOUBLE PRECISION NOT NULL,
|
||||
"category" "CashupWriteOffCategory",
|
||||
"notes" TEXT,
|
||||
"performedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "EventCashupPendingResolution_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCashupWriteOff_lineId_idx" ON "EventCashupWriteOff"("lineId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCashupPendingResolution_lineId_idx" ON "EventCashupPendingResolution"("lineId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCashupPendingResolution_performedById_idx" ON "EventCashupPendingResolution"("performedById");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupWriteOff" ADD CONSTRAINT "EventCashupWriteOff_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "EventCashupLine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupPendingResolution" ADD CONSTRAINT "EventCashupPendingResolution_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "EventCashupLine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupPendingResolution" ADD CONSTRAINT "EventCashupPendingResolution_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,83 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_created';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_deleted';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "bundleDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventBundle" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"price" DOUBLE PRECISION NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"validFrom" TIMESTAMP(3),
|
||||
"validUntil" TIMESTAMP(3),
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "EventBundle_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventBundleItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bundleId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "EventBundleItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RegistrationBundle" (
|
||||
"id" TEXT NOT NULL,
|
||||
"registrationId" TEXT NOT NULL,
|
||||
"bundleId" TEXT NOT NULL,
|
||||
"count" INTEGER NOT NULL,
|
||||
"discount" DOUBLE PRECISION NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RegistrationBundle_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventBundle_eventId_idx" ON "EventBundle"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventBundleItem_bundleId_idx" ON "EventBundleItem"("bundleId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventBundleItem_eventOptionId_idx" ON "EventBundleItem"("eventOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventBundleItem_variantId_idx" ON "EventBundleItem"("variantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationBundle_registrationId_idx" ON "RegistrationBundle"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationBundle_bundleId_idx" ON "RegistrationBundle"("bundleId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventBundle" ADD CONSTRAINT "EventBundle_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_bundleId_fkey" FOREIGN KEY ("bundleId") REFERENCES "EventBundle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationBundle" ADD CONSTRAINT "RegistrationBundle_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationBundle" ADD CONSTRAINT "RegistrationBundle_bundleId_fkey" FOREIGN KEY ("bundleId") REFERENCES "EventBundle"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BundleKind" AS ENUM ('exact', 'mix', 'free', 'group');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BundleGroupPricing" AS ENUM ('per_ticket', 'percent');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventBundle" ADD COLUMN "freeCount" INTEGER,
|
||||
ADD COLUMN "groupPricing" "BundleGroupPricing",
|
||||
ADD COLUMN "kind" "BundleKind" NOT NULL DEFAULT 'exact',
|
||||
ADD COLUMN "setSize" INTEGER;
|
||||
|
||||
+486
-180
@@ -7,7 +7,6 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
@@ -89,6 +88,7 @@ model User {
|
||||
eventsClosed Event[] @relation("EventClosedBy")
|
||||
eventsReopened Event[] @relation("EventReopenedBy")
|
||||
cashupsPerformed EventCashup[]
|
||||
pendingResolved EventCashupPendingResolution[] @relation("PendingResolutionBy")
|
||||
notifyForEvents Event[] @relation("EventNotifyRecipients")
|
||||
|
||||
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
|
||||
@@ -103,8 +103,19 @@ model User {
|
||||
couponsApplied CouponRedemption[] @relation("CouponRedemptionAppliedBy")
|
||||
|
||||
attendeeProfiles Attendee[]
|
||||
heldTickets Ticket[] @relation("TicketHolder")
|
||||
heldTickets Ticket[] @relation("TicketHolder")
|
||||
waitlistEntries WaitlistEntry[]
|
||||
scanLocks ScanLock[] @relation("ScanLockOwner")
|
||||
transfersTo TicketTransfer[] @relation("TransferRecipient")
|
||||
transfersBy TicketTransfer[] @relation("TransferActor")
|
||||
ledGroups AllocationGroup[] @relation("AllocationGroupLeader")
|
||||
feedbackInvites FeedbackInvite[]
|
||||
feedbackGiven FeedbackResponse[]
|
||||
impersonationsDone ImpersonationSession[] @relation("ImpersonationAdmin")
|
||||
transferRequests TicketTransferRequest[] @relation("TransferRequester")
|
||||
impersonatedIn ImpersonationSession[] @relation("ImpersonationTarget")
|
||||
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
|
||||
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
|
||||
|
||||
twoFactorChallenges TwoFactorChallenge[]
|
||||
trustedDevices TrustedDevice[]
|
||||
@@ -138,6 +149,16 @@ model Event {
|
||||
// them for waitlistOfferHours. waitlistPartialOffers: offer what's free even if it's
|
||||
// fewer places than asked for (otherwise skip to the next person who fits).
|
||||
waitlistEnabled Boolean @default(false)
|
||||
allowTicketTransfer Boolean @default(true)
|
||||
collectGender Boolean @default(false)
|
||||
// Sponsor a spot: public donations into a bursary pool for this event.
|
||||
allowSponsorships Boolean @default(false)
|
||||
sponsorshipGoalSpots Int?
|
||||
showSponsorshipProgress Boolean @default(true)
|
||||
feedbackForm FeedbackForm?
|
||||
analyticsEvents AnalyticsEvent[]
|
||||
analyticsDaily AnalyticsDaily[]
|
||||
allocationSets AllocationSet[]
|
||||
waitlistOfferHours Int @default(24)
|
||||
waitlistPartialOffers Boolean @default(true)
|
||||
contactName String?
|
||||
@@ -175,6 +196,8 @@ model Event {
|
||||
coupons CouponEvent[]
|
||||
consentRequests ConsentRequest[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
scanLocks ScanLock[]
|
||||
bundles EventBundle[]
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@ -198,6 +221,7 @@ model EventOption {
|
||||
costs EventCost[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -233,6 +257,7 @@ model OptionVariant {
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
@@ -243,12 +268,16 @@ model Registration {
|
||||
eventId String
|
||||
status RegistrationStatus @default(pending)
|
||||
checkoutId String? @unique
|
||||
// Sum of this registration's active coupon discounts, kept in step with
|
||||
// couponRedemptions. Stored here so every total-due calculation sees it no matter
|
||||
// which relations the caller loaded (see utils/pricing.js computeRegistrationTotalDue).
|
||||
// Sum of this registration's discounts: bundle deals plus active coupon discounts,
|
||||
// kept in step with bundleApplications and couponRedemptions. Stored here so every
|
||||
// total-due calculation sees it no matter which relations the caller loaded (see
|
||||
// utils/pricing.js computeRegistrationTotalDue).
|
||||
discountTotal Float @default(0)
|
||||
// The bundle-deal part of discountTotal (the rest is coupons).
|
||||
bundleDiscount Float @default(0)
|
||||
// Where named attendees' tickets go: all to the payer, or each to the attendee.
|
||||
ticketDelivery TicketDelivery @default(payer)
|
||||
analyticsSessionId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
@@ -258,6 +287,7 @@ model Registration {
|
||||
formResponses FormResponse[]
|
||||
formDrafts FormDraft[]
|
||||
couponRedemptions CouponRedemption[]
|
||||
bundleApplications RegistrationBundle[]
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@ -320,6 +350,11 @@ model Payment {
|
||||
registrationId String?
|
||||
eventId String?
|
||||
isDonation Boolean @default(false)
|
||||
// A "sponsor a spot" donation (also isDonation): goes into the event's sponsorship pool.
|
||||
isSponsorship Boolean @default(false)
|
||||
sponsorName String?
|
||||
sponsorMessage String?
|
||||
sponsorAnonymous Boolean @default(false)
|
||||
externalId String? @unique
|
||||
status String?
|
||||
originalPaymentId String?
|
||||
@@ -361,6 +396,8 @@ model Ticket {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
usages TicketUsage[]
|
||||
transfers TicketTransfer[]
|
||||
transferRequests TicketTransferRequest[]
|
||||
|
||||
@@index([holderUserId])
|
||||
@@index([eventId])
|
||||
@@ -370,13 +407,20 @@ model Ticket {
|
||||
}
|
||||
|
||||
model TicketUsage {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
ticketId String
|
||||
scannedById String
|
||||
scannedAt DateTime @default(now())
|
||||
quantityRedeemed Int @default(1)
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
|
||||
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
|
||||
scannedAt DateTime @default(now())
|
||||
quantityRedeemed Int @default(1)
|
||||
// Offline scanning: the device's id for the scan (makes syncing idempotent), and
|
||||
// the offline lock it was made under.
|
||||
clientScanId String? @unique
|
||||
offline Boolean @default(false)
|
||||
scanLockId String?
|
||||
syncedAt DateTime?
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
|
||||
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
|
||||
scanLock ScanLock? @relation(fields: [scanLockId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([ticketId])
|
||||
@@index([scannedAt])
|
||||
@@ -458,9 +502,22 @@ enum AdminAuditAction {
|
||||
coupon_updated
|
||||
coupon_disabled
|
||||
coupon_applied_manual
|
||||
bundle_created
|
||||
bundle_updated
|
||||
bundle_deleted
|
||||
consent_override
|
||||
consent_rerequested
|
||||
medical_list_viewed
|
||||
scan_lock_acquired
|
||||
scan_lock_released
|
||||
scan_lock_force_released
|
||||
offline_sync
|
||||
ticket_transferred
|
||||
impersonation_started
|
||||
impersonation_ended
|
||||
yoco_config_updated
|
||||
yoco_webhook_registered
|
||||
yoco_webhook_deleted
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -506,6 +563,8 @@ enum FormFieldType {
|
||||
numeric
|
||||
statement
|
||||
paragraph
|
||||
rating
|
||||
multiple_choice
|
||||
}
|
||||
|
||||
model EventForm {
|
||||
@@ -601,6 +660,7 @@ model Section {
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
allowedOptions SectionOption[]
|
||||
scanLocks ScanLock[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -659,20 +719,68 @@ model EventCashup {
|
||||
@@index([eventId, createdAt])
|
||||
}
|
||||
|
||||
// Where one method's expected takings ended up. actualAmount = bank + on hand + pending
|
||||
// (money held or on its way); write-offs explain money that's gone (fees, charges,
|
||||
// shortfalls). expected - actual - writeOffTotal is what's still unaccounted for.
|
||||
model EventCashupLine {
|
||||
id String @id @default(uuid())
|
||||
cashupId String
|
||||
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
|
||||
method String
|
||||
expectedAmount Float @default(0)
|
||||
actualAmount Float?
|
||||
variance Float?
|
||||
notes String?
|
||||
denominations EventCashupDenomination[]
|
||||
id String @id @default(uuid())
|
||||
cashupId String
|
||||
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
|
||||
method String
|
||||
expectedAmount Float @default(0)
|
||||
actualAmount Float?
|
||||
variance Float?
|
||||
notes String?
|
||||
bankAmount Float?
|
||||
onHandAmount Float?
|
||||
pendingAmount Float?
|
||||
// Card/Yoco report or POP reference for the pending amount.
|
||||
pendingReference String?
|
||||
writeOffTotal Float @default(0)
|
||||
denominations EventCashupDenomination[]
|
||||
writeOffs EventCashupWriteOff[]
|
||||
pendingResolutions EventCashupPendingResolution[]
|
||||
|
||||
@@index([cashupId])
|
||||
}
|
||||
|
||||
enum CashupWriteOffCategory {
|
||||
card_fees
|
||||
bank_charges
|
||||
cash_shortfall
|
||||
other
|
||||
}
|
||||
|
||||
model EventCashupWriteOff {
|
||||
id String @id @default(uuid())
|
||||
lineId String
|
||||
line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade)
|
||||
category CashupWriteOffCategory
|
||||
amount Float
|
||||
reason String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([lineId])
|
||||
}
|
||||
|
||||
// Pending money settled after the event was closed: it reached the bank ('received') or
|
||||
// never will ('written_off'). Append-only, like EventCashup — the line itself is never edited.
|
||||
model EventCashupPendingResolution {
|
||||
id String @id @default(uuid())
|
||||
lineId String
|
||||
line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade)
|
||||
outcome String // 'received' | 'written_off'
|
||||
amount Float
|
||||
category CashupWriteOffCategory?
|
||||
notes String?
|
||||
performedById String?
|
||||
performedBy User? @relation("PendingResolutionBy", fields: [performedById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([lineId])
|
||||
@@index([performedById])
|
||||
}
|
||||
|
||||
model EventCashupDenomination {
|
||||
id String @id @default(uuid())
|
||||
lineId String
|
||||
@@ -713,167 +821,6 @@ model EventCashupPersonCountDenomination {
|
||||
@@index([countId])
|
||||
}
|
||||
|
||||
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
|
||||
//
|
||||
// model Ministry {
|
||||
// id String @id @default(uuid())
|
||||
// slug String @unique
|
||||
// title String
|
||||
// description String?
|
||||
// image String?
|
||||
// isActive Boolean @default(true)
|
||||
// order Int @default(0)
|
||||
// leaderId String?
|
||||
// leader TeamMember? @relation(fields: [leaderId], references: [id], onDelete: SetNull)
|
||||
// interests MinistryInterest[]
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// @@index([leaderId])
|
||||
// }
|
||||
//
|
||||
// model TeamMember {
|
||||
// id String @id @default(uuid())
|
||||
// name String
|
||||
// title String
|
||||
// bio String?
|
||||
// photo String?
|
||||
// order Int @default(0)
|
||||
// isActive Boolean @default(true)
|
||||
// email String?
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// ministries Ministry[]
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// }
|
||||
//
|
||||
// model ContentBlock {
|
||||
// id String @id @default(uuid())
|
||||
// page String
|
||||
// key String
|
||||
// value String
|
||||
// type String @default("text")
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@unique([page, key])
|
||||
// @@index([page])
|
||||
// }
|
||||
//
|
||||
// model ChurchDocument {
|
||||
// id String @id @default(uuid())
|
||||
// title String
|
||||
// description String?
|
||||
// fileUrl String
|
||||
// category String @default("other")
|
||||
// isPublic Boolean @default(true)
|
||||
// uploadedAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// }
|
||||
//
|
||||
// model FellowshipGroup {
|
||||
// id String @id @default(uuid())
|
||||
// name String
|
||||
// description String?
|
||||
// leader String?
|
||||
// isActive Boolean @default(true)
|
||||
// order Int @default(0)
|
||||
// members ChurchMember[]
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([isActive, order])
|
||||
// }
|
||||
//
|
||||
// model ChurchMember {
|
||||
// id String @id @default(uuid())
|
||||
// status String @default("pending")
|
||||
// mainName String
|
||||
// mainEmail String
|
||||
// mainPhone String?
|
||||
// mainBirthday DateTime?
|
||||
// mainOccupation String?
|
||||
// mainEmploymentStatus String?
|
||||
// mainDateOfSalvation DateTime?
|
||||
// mainDateOfWaterBaptism DateTime?
|
||||
// mainHolySpiritBaptism Boolean @default(false)
|
||||
// spouseName String?
|
||||
// spouseEmail String?
|
||||
// spousePhone String?
|
||||
// spouseBirthday DateTime?
|
||||
// spouseOccupation String?
|
||||
// spouseEmploymentStatus String?
|
||||
// spouseDateOfSalvation DateTime?
|
||||
// spouseDateOfWaterBaptism DateTime?
|
||||
// spouseHolySpiritBaptism Boolean?
|
||||
// homeAddress String?
|
||||
// inFellowshipGroup Boolean @default(false)
|
||||
// fellowshipGroupId String?
|
||||
// fellowshipGroup FellowshipGroup? @relation(fields: [fellowshipGroupId], references: [id], onDelete: SetNull)
|
||||
// membershipConfirmed Boolean @default(false)
|
||||
// notes String?
|
||||
// appliedAt DateTime @default(now())
|
||||
// approvedAt DateTime?
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
// children ChurchChild[]
|
||||
// ministryInterests MinistryInterest[]
|
||||
// donations Donation[]
|
||||
//
|
||||
// @@index([status])
|
||||
// @@index([mainEmail])
|
||||
// @@index([fellowshipGroupId])
|
||||
// }
|
||||
//
|
||||
// model ChurchChild {
|
||||
// id String @id @default(uuid())
|
||||
// memberId String
|
||||
// name String
|
||||
// birthday DateTime?
|
||||
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
//
|
||||
// @@index([memberId])
|
||||
// }
|
||||
//
|
||||
// model MinistryInterest {
|
||||
// id String @id @default(uuid())
|
||||
// memberId String
|
||||
// ministryId String
|
||||
// forSpouse Boolean @default(false)
|
||||
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
// ministry Ministry @relation(fields: [ministryId], references: [id], onDelete: Cascade)
|
||||
//
|
||||
// @@unique([memberId, ministryId, forSpouse])
|
||||
// @@index([memberId])
|
||||
// @@index([ministryId])
|
||||
// }
|
||||
//
|
||||
// model Donation {
|
||||
// id String @id @default(uuid())
|
||||
// donorName String?
|
||||
// donorEmail String?
|
||||
// donorPhone String?
|
||||
// amount Float
|
||||
// method String
|
||||
// reference String?
|
||||
// note String?
|
||||
// isAnonymous Boolean @default(false)
|
||||
// linkedMemberId String?
|
||||
// linkedMember ChurchMember? @relation(fields: [linkedMemberId], references: [id], onDelete: SetNull)
|
||||
// createdAt DateTime @default(now())
|
||||
// updatedAt DateTime @updatedAt
|
||||
//
|
||||
// @@index([linkedMemberId])
|
||||
// @@index([createdAt])
|
||||
// @@index([method])
|
||||
// }
|
||||
|
||||
// ─── Coupon codes ─────────────────────────────────────────────────────────────
|
||||
// A code gives a percentage, fixed-amount or 100% (comp) discount on a registration.
|
||||
// The discount is locked into a CouponRedemption when applied, like early-bird
|
||||
// tranches: later pricing refreshes never re-price it (see utils/coupons.js).
|
||||
|
||||
enum CouponType {
|
||||
percent
|
||||
fixed
|
||||
@@ -965,6 +912,85 @@ model CouponRedemption {
|
||||
@@index([appliedById])
|
||||
}
|
||||
|
||||
// ─── Bundle deals ─────────────────────────────────────────────────────────────
|
||||
|
||||
// How a bundle is made up (see utils/bundles.js):
|
||||
// exact: these tickets in these numbers, for `price`, e.g. 5 Adult + 3 Child for R500
|
||||
// mix: any `setSize` of the qualifying tickets, for `price`
|
||||
// free: any `setSize` of the qualifying tickets, the cheapest `freeCount` free
|
||||
// group: `setSize` or more qualifying tickets, all at `price` each (or `price`% off)
|
||||
enum BundleKind {
|
||||
exact
|
||||
mix
|
||||
free
|
||||
group
|
||||
}
|
||||
|
||||
enum BundleGroupPricing {
|
||||
per_ticket
|
||||
percent
|
||||
}
|
||||
|
||||
// Tickets sold together at a better price. Applied automatically, as many times as a
|
||||
// registration's items allow (a group discount once), and only when it beats what the
|
||||
// items would cost anyway (see utils/bundles.js).
|
||||
model EventBundle {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
name String
|
||||
description String?
|
||||
kind BundleKind @default(exact)
|
||||
// exact/mix: the set's price. group: the per-ticket price, or the percentage off.
|
||||
price Float
|
||||
setSize Int?
|
||||
freeCount Int?
|
||||
groupPricing BundleGroupPricing?
|
||||
isActive Boolean @default(true)
|
||||
validFrom DateTime?
|
||||
validUntil DateTime?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
items EventBundleItem[]
|
||||
uses RegistrationBundle[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
// One line of a bundle: for exact bundles a ticket and how many; for the others a
|
||||
// qualifying ticket (quantity unused). variantId null = any variant of the option.
|
||||
model EventBundleItem {
|
||||
id String @id @default(uuid())
|
||||
bundleId String
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
quantity Int
|
||||
bundle EventBundle @relation(fields: [bundleId], references: [id], onDelete: Cascade)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([bundleId])
|
||||
@@index([eventOptionId])
|
||||
@@index([variantId])
|
||||
}
|
||||
|
||||
// How many times a bundle applies to a registration and what it saved. Rebuilt each
|
||||
// time the registration's discounts are recalculated.
|
||||
model RegistrationBundle {
|
||||
id String @id @default(uuid())
|
||||
registrationId String
|
||||
bundleId String
|
||||
count Int
|
||||
discount Float
|
||||
createdAt DateTime @default(now())
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||
bundle EventBundle @relation(fields: [bundleId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([bundleId])
|
||||
}
|
||||
|
||||
// ─── Group tickets ────────────────────────────────────────────────────────────
|
||||
|
||||
enum TicketDelivery {
|
||||
@@ -974,6 +1000,237 @@ enum TicketDelivery {
|
||||
|
||||
// One named person on a group registration, for one main-ticket place. Gets their
|
||||
// own quantity-1 ticket and their own attendee form response.
|
||||
/// An admin viewing the site as another user (strictly read-only, at most 30 minutes).
|
||||
model ImpersonationSession {
|
||||
id String @id @default(uuid())
|
||||
adminId String
|
||||
targetUserId String
|
||||
reason String
|
||||
startedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
endedAt DateTime?
|
||||
endedReason String? // 'ended' | 'expired'
|
||||
admin User @relation("ImpersonationAdmin", fields: [adminId], references: [id], onDelete: Cascade)
|
||||
target User @relation("ImpersonationTarget", fields: [targetUserId], references: [id], onDelete: Cascade)
|
||||
requests ImpersonationRequestLog[]
|
||||
|
||||
@@index([adminId])
|
||||
@@index([targetUserId])
|
||||
}
|
||||
|
||||
/// Every page viewed and API call made during an impersonation session.
|
||||
model ImpersonationRequestLog {
|
||||
id String @id @default(uuid())
|
||||
sessionId String
|
||||
method String
|
||||
path String
|
||||
blocked Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
session ImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([sessionId, createdAt])
|
||||
}
|
||||
|
||||
/// First-party funnel tracking (only after cookie consent): one row per anonymous
|
||||
/// session, event and stage ('view' | 'start' | 'submit'). "Paid" comes from the
|
||||
/// registration that carries the session id. Rows older than 90 days are rolled up
|
||||
/// into AnalyticsDaily and deleted.
|
||||
model AnalyticsEvent {
|
||||
id String @id @default(uuid())
|
||||
sessionId String
|
||||
eventId String
|
||||
stage String
|
||||
source String?
|
||||
device String?
|
||||
createdAt DateTime @default(now())
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, eventId, stage])
|
||||
@@index([eventId, createdAt])
|
||||
}
|
||||
|
||||
/// Rolled-up funnel counts: sessions that reached `stage`, per day, source and device.
|
||||
model AnalyticsDaily {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
day DateTime @db.Date
|
||||
stage String
|
||||
source String @default("direct")
|
||||
device String @default("unknown")
|
||||
sessions Int @default(0)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([eventId, day, stage, source, device])
|
||||
@@index([eventId, day])
|
||||
}
|
||||
|
||||
/// Post-event survey. Sent at sendAt to checked-in attendees (or everyone registered),
|
||||
/// with one reminder 3 days later. anonymous responses aren't linked to a person.
|
||||
model FeedbackForm {
|
||||
id String @id @default(uuid())
|
||||
eventId String @unique
|
||||
sendAt DateTime?
|
||||
audience String @default("checked_in") // 'checked_in' | 'all'
|
||||
anonymous Boolean @default(false)
|
||||
intro String?
|
||||
sentAt DateTime?
|
||||
reminderSentAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
fields FeedbackField[]
|
||||
invites FeedbackInvite[]
|
||||
responses FeedbackResponse[]
|
||||
}
|
||||
|
||||
model FeedbackField {
|
||||
id String @id @default(uuid())
|
||||
formId String
|
||||
type FormFieldType
|
||||
label String
|
||||
isRequired Boolean @default(false)
|
||||
order Int @default(0)
|
||||
helpText String?
|
||||
options Json?
|
||||
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
answers FeedbackAnswer[]
|
||||
|
||||
@@index([formId])
|
||||
}
|
||||
|
||||
/// Who was asked. tokenHash is the HMAC of the link token (the token itself isn't stored).
|
||||
model FeedbackInvite {
|
||||
id String @id @default(uuid())
|
||||
formId String
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
sentAt DateTime @default(now())
|
||||
remindedAt DateTime?
|
||||
respondedAt DateTime?
|
||||
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([formId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model FeedbackResponse {
|
||||
id String @id @default(uuid())
|
||||
formId String
|
||||
userId String? // null when the form is anonymous
|
||||
createdAt DateTime @default(now())
|
||||
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
answers FeedbackAnswer[]
|
||||
|
||||
@@index([formId])
|
||||
}
|
||||
|
||||
model FeedbackAnswer {
|
||||
id String @id @default(uuid())
|
||||
responseId String
|
||||
fieldId String
|
||||
value String
|
||||
response FeedbackResponse @relation(fields: [responseId], references: [id], onDelete: Cascade)
|
||||
field FeedbackField @relation(fields: [fieldId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([responseId])
|
||||
@@index([fieldId])
|
||||
}
|
||||
|
||||
/// A way of splitting an event's attendees up: "Rooms", "Small groups", "Buses".
|
||||
model AllocationSet {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
name String
|
||||
showOnTicket Boolean @default(false)
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
groups AllocationGroup[]
|
||||
assignments AllocationAssignment[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
/// One room / group / bus in a set. gender ('male' | 'female') restricts who can join.
|
||||
model AllocationGroup {
|
||||
id String @id @default(uuid())
|
||||
setId String
|
||||
name String
|
||||
capacity Int?
|
||||
gender String?
|
||||
leaderUserId String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
leader User? @relation("AllocationGroupLeader", fields: [leaderUserId], references: [id], onDelete: SetNull)
|
||||
assignments AllocationAssignment[]
|
||||
|
||||
@@index([setId])
|
||||
@@index([leaderUserId])
|
||||
}
|
||||
|
||||
/// An attendee placed in a group. One group per set.
|
||||
model AllocationAssignment {
|
||||
id String @id @default(uuid())
|
||||
setId String
|
||||
groupId String
|
||||
attendeeId String
|
||||
createdAt DateTime @default(now())
|
||||
set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
group AllocationGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([setId, attendeeId])
|
||||
@@index([groupId])
|
||||
@@index([attendeeId])
|
||||
}
|
||||
|
||||
/// A transfer waiting for the new holder to accept it from the link sent to the email /
|
||||
/// phone the sender typed. Nothing changes on the ticket until it's accepted. `details`
|
||||
/// holds what the sender entered (date of birth, gender, parent details on youth events).
|
||||
model TicketTransferRequest {
|
||||
id String @id @default(uuid())
|
||||
ticketId String
|
||||
requestedById String
|
||||
toName String
|
||||
toEmail String?
|
||||
toPhone String?
|
||||
details Json?
|
||||
tokenHash String @unique
|
||||
status String @default("pending") // pending | accepted | declined | cancelled | expired
|
||||
expiresAt DateTime
|
||||
respondedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
requestedBy User @relation("TransferRequester", fields: [requestedById], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([ticketId, status])
|
||||
}
|
||||
|
||||
/// A ticket handed to someone else. oldQrCode is the code that stopped working (null
|
||||
/// when one place was split off a shared group ticket, whose code stays valid).
|
||||
model TicketTransfer {
|
||||
id String @id @default(uuid())
|
||||
ticketId String
|
||||
fromHolderName String
|
||||
fromUserId String?
|
||||
toName String
|
||||
toUserId String?
|
||||
byUserId String?
|
||||
oldQrCode String?
|
||||
createdAt DateTime @default(now())
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
toUser User? @relation("TransferRecipient", fields: [toUserId], references: [id], onDelete: SetNull)
|
||||
byUser User? @relation("TransferActor", fields: [byUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([ticketId])
|
||||
@@index([oldQrCode])
|
||||
}
|
||||
|
||||
model Attendee {
|
||||
id String @id @default(uuid())
|
||||
registrationId String
|
||||
@@ -998,6 +1255,7 @@ model Attendee {
|
||||
ticket Ticket?
|
||||
formResponses FormResponse[]
|
||||
consentRequests ConsentRequest[]
|
||||
allocations AllocationAssignment[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([registrationOptionId])
|
||||
@@ -1085,3 +1343,51 @@ model WaitlistEntry {
|
||||
@@index([userId])
|
||||
@@index([registrationId])
|
||||
}
|
||||
|
||||
// ─── Offline scanning ─────────────────────────────────────────────────────────
|
||||
|
||||
// Locks a section (or the whole event, sectionId null) to one device for offline
|
||||
// scanning, so two devices can never redeem the same ticket while offline.
|
||||
// One active (releasedAt null) lock per scope — enforced in utils/scanLocks.js.
|
||||
model ScanLock {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
sectionId String?
|
||||
deviceId String
|
||||
deviceName String?
|
||||
userId String
|
||||
lockedAt DateTime @default(now())
|
||||
snapshotAt DateTime?
|
||||
lastSyncAt DateTime?
|
||||
releasedAt DateTime?
|
||||
forceReleasedById String?
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||
user User @relation("ScanLockOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
forceReleasedBy User? @relation("ScanLockForcedBy", fields: [forceReleasedById], references: [id], onDelete: SetNull)
|
||||
usages TicketUsage[]
|
||||
syncReports OfflineSyncReport[]
|
||||
|
||||
@@index([eventId, releasedAt])
|
||||
@@index([userId])
|
||||
@@index([sectionId])
|
||||
@@index([forceReleasedById])
|
||||
}
|
||||
|
||||
// One sync of queued offline scans; `conflicts` lists scans that needed a person to look.
|
||||
model OfflineSyncReport {
|
||||
id String @id @default(uuid())
|
||||
scanLockId String
|
||||
accepted Int @default(0)
|
||||
duplicates Int @default(0)
|
||||
conflicts Json @default("[]")
|
||||
afterForceRelease Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
reviewedAt DateTime?
|
||||
reviewedById String?
|
||||
scanLock ScanLock @relation(fields: [scanLockId], references: [id], onDelete: Cascade)
|
||||
reviewedBy User? @relation("SyncReportReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([scanLockId])
|
||||
@@index([reviewedById])
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const dotenv = require('dotenv');
|
||||
// Load .env from the backend root
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { createPrismaClient } = require('../src/config/db');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
@@ -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'] },
|
||||
];
|
||||
@@ -86,7 +90,7 @@ const MAPPINGS = [
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(' Settings migration: .env → DB');
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* Run: node scripts/seed-users.js
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
|
||||
const { createPrismaClient } = require('../src/config/db');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
const firstNames = [
|
||||
'Liam', 'Olivia', 'Noah', 'Emma', 'James', 'Ava', 'Elijah', 'Sophia',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,17 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// Prisma 7 talks to Postgres through the node-postgres driver adapter instead of
|
||||
// the old Rust engine. pg has no connect timeout by default; 5s matches Prisma 5.
|
||||
function createPrismaClient() {
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
module.exports = prisma;
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
module.exports = prisma;
|
||||
module.exports.createPrismaClient = createPrismaClient;
|
||||
|
||||
@@ -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,325 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { KINDS, describeBundle, describeBundleItems, isBundleAvailable } = require('../utils/bundles');
|
||||
const { previewBundles } = require('../utils/coupons');
|
||||
const { basketBuyer, priceBasket } = require('./couponController');
|
||||
|
||||
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
|
||||
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
|
||||
const GROUP_PRICING = ['per_ticket', 'percent'];
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/** Standard (variant or option) price of one unit of an item. */
|
||||
function itemPrice(i) {
|
||||
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
|
||||
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the deal is worth at standard prices: `standardValue` is what an exact set costs
|
||||
* separately (or the dearest pick-any set); `savingText` is a short "save …" line, or
|
||||
* null when the headline already says it.
|
||||
*/
|
||||
function valueOf(bundle) {
|
||||
const kind = bundle.kind || 'exact';
|
||||
const items = bundle.items || [];
|
||||
const dearest = items.reduce((m, i) => Math.max(m, itemPrice(i)), 0);
|
||||
const price = Number(bundle.price || 0);
|
||||
if (kind === 'exact') {
|
||||
const standard = round2(items.reduce((s, i) => s + itemPrice(i) * Number(i.quantity || 0), 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'mix') {
|
||||
const standard = round2(dearest * Number(bundle.setSize || 0));
|
||||
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
|
||||
}
|
||||
if (kind === 'free') {
|
||||
return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
|
||||
}
|
||||
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) {
|
||||
return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
|
||||
}
|
||||
return { standardValue: null, savingText: null };
|
||||
}
|
||||
|
||||
// Items with prices loaded, so the standard value can be shown next to the bundle price.
|
||||
const DETAIL_INCLUDE = {
|
||||
items: {
|
||||
include: {
|
||||
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function shape(bundle, now = new Date()) {
|
||||
const items = (bundle.items || []).map(({ eventOption, ...i }) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
|
||||
return {
|
||||
...bundle,
|
||||
items,
|
||||
summary: describeBundleItems(bundle),
|
||||
headline: describeBundle(bundle),
|
||||
...valueOf(bundle),
|
||||
available: isBundleAvailable(bundle, now),
|
||||
};
|
||||
}
|
||||
|
||||
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
|
||||
|
||||
/**
|
||||
* Turns a create/update body into Prisma data plus the item list. On update, fields
|
||||
* left out of the body are left alone; when any pricing field is sent, the bundle as a
|
||||
* whole (saved values plus the new ones) is checked. Items must belong to the event.
|
||||
*/
|
||||
async function parseBundleBody(body, eventId, { isCreate, current = null }) {
|
||||
const data = {};
|
||||
if (isCreate || body.name !== undefined) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) throw new Error('Give the bundle a name');
|
||||
if (name.length > 80) throw new Error('Keep the name under 80 characters');
|
||||
data.name = name;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.isActive !== undefined) data.isActive = !!body.isActive;
|
||||
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
|
||||
const validFrom = optionalDate(body.validFrom, 'Available from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Available until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Available from" must be before "Available until"');
|
||||
|
||||
let items;
|
||||
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items };
|
||||
|
||||
const pick = (f) => (body[f] !== undefined ? body[f] : current?.[f]);
|
||||
const kind = pick('kind') || 'exact';
|
||||
if (!KINDS.includes(kind)) throw new Error('Unknown bundle type');
|
||||
data.kind = kind;
|
||||
|
||||
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
|
||||
// The same option/variant twice becomes one line.
|
||||
const merged = new Map();
|
||||
for (const it of Array.isArray(rawItems) ? rawItems : []) {
|
||||
if (!it || !it.eventOptionId) continue;
|
||||
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
|
||||
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each ticket needs a quantity of at least 1');
|
||||
const key = `${it.eventOptionId}::${it.variantId || ''}`;
|
||||
const prev = merged.get(key);
|
||||
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
|
||||
}
|
||||
items = [...merged.values()];
|
||||
if (items.length === 0) throw new Error(kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
|
||||
|
||||
const price = Number(pick('price'));
|
||||
const setSize = Number(pick('setSize'));
|
||||
const freeCount = Number(pick('freeCount'));
|
||||
const groupPricing = pick('groupPricing');
|
||||
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
|
||||
|
||||
if (kind === 'exact') {
|
||||
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
|
||||
if (!priceGiven || !(price >= 0)) throw new Error('Enter the bundle price (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'mix') {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A pick-any bundle needs at least 2 tickets per set');
|
||||
if (!priceGiven || !(price >= 0)) throw new Error('Enter the price for the set (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
|
||||
} else if (kind === 'free') {
|
||||
if (!Number.isInteger(freeCount) || freeCount < 1) throw new Error('At least one ticket must be free');
|
||||
if (!Number.isInteger(setSize) || setSize <= freeCount) throw new Error('Buy at least one ticket to get the free ones');
|
||||
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
|
||||
} else {
|
||||
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A group needs at least 2 tickets');
|
||||
if (!GROUP_PRICING.includes(groupPricing)) throw new Error('Choose a price per ticket or a percentage off');
|
||||
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) throw new Error('Enter a percentage between 1 and 100');
|
||||
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) throw new Error('Enter the price per ticket (zero or more)');
|
||||
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
|
||||
}
|
||||
|
||||
const options = await prisma.eventOption.findMany({
|
||||
where: { id: { in: items.map(i => i.eventOptionId) } },
|
||||
select: { id: true, eventId: true, variants: { select: { id: true } } },
|
||||
});
|
||||
for (const i of items) {
|
||||
const row = options.find(o => o.id === i.eventOptionId);
|
||||
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
|
||||
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
|
||||
}
|
||||
return { data, items };
|
||||
}
|
||||
|
||||
async function eventOr404(eventId, res) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
return event;
|
||||
}
|
||||
|
||||
// @desc An event's bundle deals with how often each has been used
|
||||
// @route GET /api/bundles/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const listBundles = async (req, res) => {
|
||||
try {
|
||||
await eventOr404(req.params.eventId, res);
|
||||
const bundles = await prisma.eventBundle.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: DETAIL_INCLUDE,
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const totals = await prisma.registrationBundle.groupBy({
|
||||
by: ['bundleId'],
|
||||
where: { bundle: { eventId: req.params.eventId }, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { count: true, discount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
|
||||
const now = new Date();
|
||||
res.json(bundles.map(b => ({
|
||||
...shape(b, now),
|
||||
uses: byId[b.id]?._sum?.count || 0,
|
||||
registrations: byId[b.id]?._count?._all || 0,
|
||||
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
|
||||
})));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create a bundle deal
|
||||
// @route POST /api/bundles/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const createBundle = async (req, res) => {
|
||||
try {
|
||||
const event = await eventOr404(req.params.eventId, res);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseBundleBody(req.body || {}, event.id, { isCreate: true });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
const bundle = await prisma.eventBundle.create({
|
||||
data: { ...parsed.data, eventId: event.id, items: { create: parsed.items } },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_created',
|
||||
targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: describeBundle(bundle) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.status(201).json({ ...shape(bundle), uses: 0, registrations: 0, totalDiscount: 0 });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Edit a bundle deal (including switching it off). Registrations that already
|
||||
// have it are only re-worked when their own items change.
|
||||
// @route PUT /api/bundles/:id
|
||||
// @access Private/Supervisor
|
||||
const updateBundle = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id }, include: { items: true } });
|
||||
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false, current: existing });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
const bundle = await prisma.eventBundle.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
|
||||
include: DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_updated',
|
||||
targetType: 'EventBundle', targetId: bundle.id,
|
||||
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(req.body || {}) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json(shape(bundle));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Delete a bundle deal. One that's been used is switched off instead, so the
|
||||
// registrations that got it keep their price.
|
||||
// @route DELETE /api/bundles/:id
|
||||
// @access Private/Supervisor
|
||||
const deleteBundle = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
|
||||
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
|
||||
if (used > 0) {
|
||||
await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
|
||||
} else {
|
||||
await prisma.eventBundle.delete({ where: { id: existing.id } });
|
||||
}
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_deleted',
|
||||
targetType: 'EventBundle', targetId: existing.id,
|
||||
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json(used > 0
|
||||
? { switchedOff: true, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
|
||||
: { deleted: true, message: 'Bundle deleted' });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The bundle deals a basket gets, before any coupon code
|
||||
// @route POST /api/bundles/quote
|
||||
// @access Public. Body: { eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
|
||||
const quoteBundles = async (req, res) => {
|
||||
try {
|
||||
const { eventId, options, forUserId } = req.body || {};
|
||||
if (!eventId || !Array.isArray(options)) { res.status(400); throw new Error('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 { buyer } = await basketBuyer(req, forUserId);
|
||||
const lines = await priceBasket(event, options, buyer);
|
||||
const subtotal = round2(lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0));
|
||||
const bundles = await previewBundles(eventId, lines);
|
||||
res.json({
|
||||
subtotal,
|
||||
bundleDiscount: bundles.discount,
|
||||
bundles: bundles.applications,
|
||||
total: round2(Math.max(0, subtotal - bundles.discount)),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/** The deals to show on an event's page: available bundles, without internal fields. */
|
||||
async function publicBundlesForEvent(eventId) {
|
||||
const bundles = await prisma.eventBundle.findMany({
|
||||
where: { eventId, isActive: true },
|
||||
include: DETAIL_INCLUDE,
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const now = new Date();
|
||||
return bundles.filter(b => isBundleAvailable(b, now)).map(b => {
|
||||
const s = shape(b, now);
|
||||
return {
|
||||
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
|
||||
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
|
||||
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
|
||||
items: s.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent };
|
||||
@@ -1,7 +1,20 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { ALL_METHODS, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
|
||||
const { ALL_METHODS, WRITE_OFF_CATEGORIES, PENDING_OUTCOMES, settleLine, applyPendingResolutions, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
|
||||
|
||||
// What the cashup screen sends per method: where the money ended up and what was written off.
|
||||
function cleanDraftLine(l) {
|
||||
return {
|
||||
method: l.method,
|
||||
bankAmount: l.bankAmount ?? null,
|
||||
onHandAmount: l.onHandAmount ?? null,
|
||||
pendingAmount: l.pendingAmount ?? null,
|
||||
pendingReference: l.pendingReference || null,
|
||||
writeOffs: (Array.isArray(l.writeOffs) ? l.writeOffs : []).map(w => ({ category: w.category, amount: w.amount ?? null, reason: w.reason || null })),
|
||||
notes: l.notes || null
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
|
||||
// @route GET /api/cashups/event/:eventId
|
||||
@@ -66,7 +79,7 @@ const saveEventCashupDraft = async (req, res) => {
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: { cashupDraft: { lines: Array.isArray(lines) ? lines : [], notes: notes || null, savedAt: new Date().toISOString() } }
|
||||
data: { cashupDraft: { lines: (Array.isArray(lines) ? lines : []).filter(l => l && ALL_METHODS.includes(l.method)).map(cleanDraftLine), notes: notes || null, savedAt: new Date().toISOString() } }
|
||||
});
|
||||
|
||||
res.json({ message: 'Draft saved' });
|
||||
@@ -88,40 +101,32 @@ const closeEvent = async (req, res) => {
|
||||
const financials = await computeEventFinancials(eventId);
|
||||
const isFullCashup = Array.isArray(lines) && lines.length > 0;
|
||||
|
||||
// Each method says where its expected money ended up (see settleLine). Cash is never a
|
||||
// single event-wide figure: when staff counted it, the count is the cash there is, sourced
|
||||
// here from the per-person counts rather than from whatever the frontend sent.
|
||||
let cashupLines = [];
|
||||
if (isFullCashup) {
|
||||
const nonCashLines = lines
|
||||
.filter(l => l && ALL_METHODS.includes(l.method) && l.method !== 'cash')
|
||||
.map(l => {
|
||||
const expected = financials.expectedCashByMethod[l.method] || 0;
|
||||
const actual = l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null;
|
||||
return {
|
||||
id: uuidv4(),
|
||||
method: l.method,
|
||||
expectedAmount: expected,
|
||||
actualAmount: actual,
|
||||
variance: actual !== null ? actual - expected : null,
|
||||
notes: l.notes || null
|
||||
};
|
||||
});
|
||||
|
||||
// Cash is never entered as a single event-wide figure — it's always the live sum of every
|
||||
// staff member's per-person count (see computeEventCashActualFromPersonCounts), so it's
|
||||
// always sourced here rather than from whatever (if anything) the frontend sent for it.
|
||||
const cashLineInput = lines.find(l => l && l.method === 'cash');
|
||||
const { actual: cashActual, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
|
||||
const cashExpected = financials.expectedCashByMethod.cash || 0;
|
||||
const cashLine = {
|
||||
id: uuidv4(),
|
||||
method: 'cash',
|
||||
expectedAmount: cashExpected,
|
||||
actualAmount: cashActual,
|
||||
variance: cashActual !== null ? cashActual - cashExpected : null,
|
||||
notes: cashLineInput?.notes || null,
|
||||
denominations: cashDenominations.length > 0 ? { create: cashDenominations } : undefined
|
||||
};
|
||||
|
||||
cashupLines = [cashLine, ...nonCashLines];
|
||||
const { actual: cashCounted, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
|
||||
cashupLines = ALL_METHODS.map(method => {
|
||||
const input = lines.find(l => l && l.method === method) || {};
|
||||
const expected = financials.expectedCashByMethod[method] || 0;
|
||||
const settled = settleLine({ method, expected, counted: method === 'cash' ? cashCounted : null, input });
|
||||
return {
|
||||
id: uuidv4(),
|
||||
method,
|
||||
expectedAmount: expected,
|
||||
actualAmount: settled.actual,
|
||||
variance: settled.variance,
|
||||
notes: input.notes || null,
|
||||
bankAmount: settled.bankAmount,
|
||||
onHandAmount: settled.onHandAmount,
|
||||
pendingAmount: settled.pendingAmount,
|
||||
pendingReference: settled.pendingReference,
|
||||
writeOffTotal: settled.writeOffTotal,
|
||||
writeOffs: settled.writeOffs.length > 0 ? { create: settled.writeOffs } : undefined,
|
||||
denominations: method === 'cash' && cashDenominations.length > 0 ? { create: cashDenominations } : undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const totalActualRevenue = isFullCashup
|
||||
@@ -142,7 +147,7 @@ const closeEvent = async (req, res) => {
|
||||
performedById: req.user.id,
|
||||
lines: { create: cashupLines }
|
||||
},
|
||||
include: { lines: { include: { denominations: true } }, performedBy: { select: { id: true, name: true, email: true } } }
|
||||
include: { lines: { include: { denominations: true, writeOffs: true } }, performedBy: { select: { id: true, name: true, email: true } } }
|
||||
});
|
||||
|
||||
await prisma.event.update({
|
||||
@@ -185,9 +190,33 @@ const reopenEvent = async (req, res) => {
|
||||
include: { performedBy: { select: { id: true, name: true, email: true } } }
|
||||
});
|
||||
|
||||
// Start the next cashup from the last one, with pending money that has since been
|
||||
// received or written off folded in, so nothing has to be typed in again.
|
||||
const lastClose = await prisma.eventCashup.findFirst({
|
||||
where: { eventId, action: { in: ['closed', 'quick_closed'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { lines: { include: { writeOffs: true, pendingResolutions: true } } }
|
||||
});
|
||||
const cashupDraft = lastClose && lastClose.lines.length > 0 ? {
|
||||
lines: lastClose.lines.map(l => {
|
||||
const settled = applyPendingResolutions(l);
|
||||
return cleanDraftLine({
|
||||
method: l.method,
|
||||
bankAmount: settled.bank,
|
||||
onHandAmount: settled.onHand,
|
||||
pendingAmount: settled.pendingOutstanding || null,
|
||||
pendingReference: settled.pendingOutstanding ? settled.pendingReference : null,
|
||||
writeOffs: settled.writeOffs,
|
||||
notes: l.notes
|
||||
});
|
||||
}),
|
||||
notes: null,
|
||||
savedAt: new Date().toISOString()
|
||||
} : undefined;
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id }
|
||||
data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id, ...(cashupDraft ? { cashupDraft } : {}) }
|
||||
});
|
||||
|
||||
res.status(201).json(auditRow);
|
||||
@@ -196,6 +225,66 @@ const reopenEvent = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Settle money that was pending when the event was closed (on the card report, or a
|
||||
// POP we hadn't seen in the bank yet): it has now reached the bank, or it never will and
|
||||
// is written off. Allowed while the event is closed — no reopen needed.
|
||||
// @route POST /api/cashups/lines/:lineId/pending
|
||||
// @access Private/Admin
|
||||
const resolvePending = async (req, res) => {
|
||||
try {
|
||||
const { lineId } = req.params;
|
||||
const { outcome, amount, category, notes } = req.body;
|
||||
|
||||
if (!PENDING_OUTCOMES.includes(outcome)) {
|
||||
res.status(400);
|
||||
throw new Error('Say whether the money was received or written off');
|
||||
}
|
||||
const value = Math.round(Number(amount) * 100) / 100;
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
res.status(400);
|
||||
throw new Error('Enter an amount more than R0');
|
||||
}
|
||||
|
||||
const resolution = await prisma.$transaction(async (tx) => {
|
||||
// Lock the line so two people settling at once can't settle more than is pending.
|
||||
await tx.$executeRaw`SELECT 1 FROM "EventCashupLine" WHERE id = ${lineId} FOR UPDATE`;
|
||||
const line = await tx.eventCashupLine.findUnique({
|
||||
where: { id: lineId },
|
||||
include: { cashup: { select: { id: true, eventId: true } }, pendingResolutions: true }
|
||||
});
|
||||
if (!line) {
|
||||
res.status(404);
|
||||
throw new Error('Cashup line not found');
|
||||
}
|
||||
const latest = await tx.eventCashup.findFirst({ where: { eventId: line.cashup.eventId }, orderBy: { createdAt: 'desc' }, select: { id: true } });
|
||||
if (!latest || latest.id !== line.cashupId) {
|
||||
res.status(400);
|
||||
throw new Error('Only the current cashup of a closed event can be updated');
|
||||
}
|
||||
const { pendingOutstanding } = applyPendingResolutions(line);
|
||||
if (value > pendingOutstanding + 0.005) {
|
||||
res.status(400);
|
||||
throw new Error(`Only R${pendingOutstanding.toFixed(2)} is still pending`);
|
||||
}
|
||||
return tx.eventCashupPendingResolution.create({
|
||||
data: {
|
||||
lineId,
|
||||
outcome,
|
||||
amount: value,
|
||||
category: outcome === 'written_off' ? (WRITE_OFF_CATEGORIES.includes(category) ? category : 'other') : null,
|
||||
notes: notes ? String(notes).trim().slice(0, 200) : null,
|
||||
performedById: req.user.id
|
||||
},
|
||||
include: { performedBy: { select: { id: true, name: true } } }
|
||||
});
|
||||
});
|
||||
|
||||
res.status(201).json(resolution);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Flat audit log of every close/quick-close/reopen, optionally filtered
|
||||
// @route GET /api/cashups/audit
|
||||
// @access Private/Supervisor
|
||||
@@ -234,5 +323,6 @@ module.exports = {
|
||||
saveEventCashupDraft,
|
||||
closeEvent,
|
||||
reopenEvent,
|
||||
resolvePending,
|
||||
getCashupAudit
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { describeBundle } = require('../utils/bundles');
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
@@ -231,6 +232,39 @@ const updateCoupon = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Who a basket is being priced for: the logged-in buyer, or, for staff, the attendee
|
||||
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
|
||||
*/
|
||||
async function basketBuyer(req, forUserId) {
|
||||
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
|
||||
}
|
||||
return { buyer, isStaff };
|
||||
}
|
||||
|
||||
/** Prices a basket the same way registration will, as coupon/bundle lines. */
|
||||
async function priceBasket(event, options, buyer) {
|
||||
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 }],
|
||||
});
|
||||
}
|
||||
return buildCouponLines({ registrationOptions });
|
||||
}
|
||||
|
||||
// @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)
|
||||
@@ -249,33 +283,12 @@ const validateCode = async (req, res) => {
|
||||
});
|
||||
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 { buyer, isStaff } = await basketBuyer(req, forUserId);
|
||||
const lines = await priceBasket(event, options, buyer);
|
||||
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
|
||||
try {
|
||||
const { coupon, discount } = await previewCoupon({
|
||||
const { coupon, discount, bundles } = await previewCoupon({
|
||||
code, eventId, user: buyer, lines,
|
||||
deferRestrictions: isStaff && !buyer,
|
||||
});
|
||||
@@ -287,8 +300,10 @@ const validateCode = async (req, res) => {
|
||||
summary: describeCoupon(coupon),
|
||||
description: coupon.description,
|
||||
discount,
|
||||
bundleDiscount: bundles.discount,
|
||||
bundles: bundles.applications,
|
||||
subtotal,
|
||||
total: Math.max(0, subtotal - discount),
|
||||
total: Math.max(0, subtotal - bundles.discount - discount),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.isCouponError) throw e;
|
||||
@@ -299,7 +314,7 @@ const validateCode = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Coupon uses for the Discounts report
|
||||
// @desc Coupon and bundle-deal uses for the Discounts report
|
||||
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
|
||||
// @access Private/Supervisor
|
||||
const listRedemptions = async (req, res) => {
|
||||
@@ -322,10 +337,34 @@ const listRedemptions = async (req, res) => {
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
res.json(rows.map(r => ({ ...r, summary: describeCoupon(r.coupon) })));
|
||||
// Bundle deals, in the same shape. Their rows are rebuilt whenever a registration's
|
||||
// discounts are recalculated, so dates go by when the registration was made.
|
||||
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
|
||||
const bundleRows = await prisma.registrationBundle.findMany({
|
||||
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate } },
|
||||
include: {
|
||||
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
|
||||
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
res.json([
|
||||
...rows.map(r => ({ ...r, kind: 'coupon', uses: 1, summary: describeCoupon(r.coupon) })),
|
||||
...bundleRows.map(b => ({
|
||||
id: b.id,
|
||||
kind: 'bundle',
|
||||
uses: b.count,
|
||||
discountAmount: b.discount,
|
||||
createdAt: b.registration.createdAt,
|
||||
summary: describeBundle(b.bundle),
|
||||
coupon: { id: b.bundleId, code: b.bundle.name },
|
||||
user: b.registration.user,
|
||||
appliedBy: null,
|
||||
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
|
||||
})),
|
||||
]);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
|
||||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions, basketBuyer, priceBasket };
|
||||
|
||||
@@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) {
|
||||
// @access Private/Admin
|
||||
const createEvent = async (req, res) => {
|
||||
try {
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
@@ -70,6 +70,11 @@ const createEvent = async (req, res) => {
|
||||
consentCollectEmergency: consentCollectEmergency === true,
|
||||
consentCollectPhoto: consentCollectPhoto === true,
|
||||
waitlistEnabled: waitlistEnabled === true,
|
||||
allowTicketTransfer: allowTicketTransfer !== false,
|
||||
collectGender: collectGender === true,
|
||||
allowSponsorships: allowSponsorships === true,
|
||||
sponsorshipGoalSpots: sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null,
|
||||
showSponsorshipProgress: showSponsorshipProgress !== false,
|
||||
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
|
||||
waitlistPartialOffers: waitlistPartialOffers !== false,
|
||||
contactName: contactName || null,
|
||||
@@ -452,6 +457,7 @@ const getEventById = async (req, res) => {
|
||||
url: toAbsoluteUrl(req, att.url)
|
||||
})),
|
||||
form,
|
||||
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
|
||||
};
|
||||
res.json(mapped);
|
||||
} catch (error) {
|
||||
@@ -536,7 +542,7 @@ const updateEvent = async (req, res) => {
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
@@ -559,6 +565,11 @@ const updateEvent = async (req, res) => {
|
||||
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
|
||||
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
|
||||
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
|
||||
allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer,
|
||||
collectGender: collectGender !== undefined ? collectGender === true : event.collectGender,
|
||||
allowSponsorships: allowSponsorships !== undefined ? allowSponsorships === true : event.allowSponsorships,
|
||||
sponsorshipGoalSpots: sponsorshipGoalSpots !== undefined ? (sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null) : event.sponsorshipGoalSpots,
|
||||
showSponsorshipProgress: showSponsorshipProgress !== undefined ? showSponsorshipProgress !== false : event.showSponsorshipProgress,
|
||||
waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours,
|
||||
waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers,
|
||||
// Editing the consent text starts a new version; parents who already agreed keep
|
||||
@@ -1329,6 +1340,11 @@ const emailEventAttendees = async (req, res) => {
|
||||
where.status = 'cancelled';
|
||||
}
|
||||
|
||||
// Only registrations with someone in this allocation group ("everyone on Bus 2").
|
||||
if (filter?.allocationGroupId) {
|
||||
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
|
||||
}
|
||||
|
||||
// Optional explicit attendee selection by userIds
|
||||
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
|
||||
if (attendeeIds.length > 0) {
|
||||
@@ -1379,6 +1395,24 @@ const emailEventAttendees = async (req, res) => {
|
||||
}
|
||||
const recipients = Array.from(uniqMap.values());
|
||||
|
||||
// Sponsors of this event instead of its attendees (e.g. the post-event thank-you).
|
||||
if (filter?.audience === 'sponsors') {
|
||||
recipients.length = 0;
|
||||
registrations.length = 0;
|
||||
const sponsorships = await prisma.payment.findMany({
|
||||
where: { eventId, isDonation: true, isSponsorship: true, amount: { gt: 0 } },
|
||||
include: { user: { select: { name: true, email: true, isActive: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const seen = new Set();
|
||||
for (const s of sponsorships) {
|
||||
const email = (s.user?.email || '').trim();
|
||||
if (!email || email.endsWith('@guest.local') || email.endsWith('@deleted.invalid') || seen.has(email)) continue;
|
||||
seen.add(email);
|
||||
recipients.push({ email, name: s.sponsorName || s.user?.name || '' });
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
}
|
||||
@@ -1567,6 +1601,11 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
where.status = 'cancelled';
|
||||
}
|
||||
|
||||
// Only registrations with someone in this allocation group ("everyone on Bus 2").
|
||||
if (filter?.allocationGroupId) {
|
||||
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
|
||||
}
|
||||
|
||||
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
|
||||
if (attendeeIds.length > 0) {
|
||||
where.userId = { in: attendeeIds };
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, sendFeedbackInvites, findInviteByToken } = require('../utils/feedback');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
const shape = (fields) => JSON.stringify(fields.map(f => [f.type, f.label, f.isRequired, f.options || null, f.helpText || null]));
|
||||
|
||||
async function formWithStats(eventId) {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { eventId }, include: { fields: { orderBy: { order: 'asc' } } } });
|
||||
if (!form) return null;
|
||||
const [invited, responses] = await Promise.all([
|
||||
prisma.feedbackInvite.count({ where: { formId: form.id } }),
|
||||
prisma.feedbackResponse.count({ where: { formId: form.id } }),
|
||||
]);
|
||||
return { ...form, invited, responses };
|
||||
}
|
||||
|
||||
// @desc An event's feedback form (with invite/response counts)
|
||||
// @route GET /api/feedback/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const getEventFeedback = async (req, res) => {
|
||||
try {
|
||||
res.json(await formWithStats(req.params.eventId));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create or update an event's feedback form
|
||||
// @route PUT /api/feedback/event/:eventId { sendAt, audience, anonymous, intro, fields[] }
|
||||
// @access Private/Supervisor
|
||||
const saveEventFeedback = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, select: { id: true, endDate: true } });
|
||||
if (!event) throw feedbackError('Event not found', 404);
|
||||
const { sendAt, audience, anonymous, intro, fields } = req.body || {};
|
||||
const clean = cleanFields(fields);
|
||||
let when = null;
|
||||
if (sendAt) {
|
||||
when = new Date(sendAt);
|
||||
if (isNaN(when.getTime())) throw feedbackError('Enter a valid send time.');
|
||||
}
|
||||
const data = {
|
||||
sendAt: when,
|
||||
audience: audience === 'all' ? 'all' : 'checked_in',
|
||||
anonymous: anonymous === true,
|
||||
intro: intro ? String(intro).slice(0, 2000) : null,
|
||||
};
|
||||
|
||||
const existing = await prisma.feedbackForm.findUnique({ where: { eventId: event.id }, include: { fields: { orderBy: { order: 'asc' } } } });
|
||||
if (!existing) {
|
||||
await prisma.feedbackForm.create({ data: { ...data, eventId: event.id, fields: { create: clean } } });
|
||||
} else {
|
||||
const answered = await prisma.feedbackResponse.count({ where: { formId: existing.id } });
|
||||
const changed = shape(existing.fields) !== shape(clean);
|
||||
if (answered > 0 && changed) throw feedbackError('People have already answered this feedback form, so its questions can’t be changed.', 409);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.feedbackForm.update({ where: { id: existing.id }, data: existing.sentAt ? { ...data, sendAt: existing.sendAt } : data });
|
||||
if (changed) {
|
||||
await tx.feedbackField.deleteMany({ where: { formId: existing.id } });
|
||||
await tx.feedbackField.createMany({ data: clean.map(f => ({ ...f, formId: existing.id, options: f.options ?? undefined })) });
|
||||
}
|
||||
});
|
||||
}
|
||||
res.json(await formWithStats(event.id));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/feedback/event/:eventId
|
||||
const deleteEventFeedback = async (req, res) => {
|
||||
try {
|
||||
await prisma.feedbackForm.deleteMany({ where: { eventId: req.params.eventId } });
|
||||
res.json({ message: 'Feedback form removed' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send the feedback form now (instead of waiting for its send time)
|
||||
// @route POST /api/feedback/event/:eventId/send-now
|
||||
// @access Private/Supervisor
|
||||
const sendNow = async (req, res) => {
|
||||
try {
|
||||
const form = await prisma.feedbackForm.findUnique({ where: { eventId: req.params.eventId } });
|
||||
if (!form) throw feedbackError('This event has no feedback form.', 404);
|
||||
if (!form.sentAt) await prisma.feedbackForm.update({ where: { id: form.id }, data: { sentAt: new Date(), sendAt: form.sendAt || new Date() } });
|
||||
// Also invites anyone who checked in after the first send.
|
||||
const invited = await sendFeedbackInvites(form.id);
|
||||
res.json({ invited, message: invited ? `Feedback form sent to ${invited} ${invited === 1 ? 'person' : 'people'}.` : 'Everyone has already been sent the form.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The form behind a personal feedback link
|
||||
// @route GET /api/feedback/public/:token
|
||||
// @access Public (link holder)
|
||||
const getPublicForm = async (req, res) => {
|
||||
try {
|
||||
const invite = await findInviteByToken(req.params.token);
|
||||
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
|
||||
const { form } = invite;
|
||||
res.json({
|
||||
event: form.event,
|
||||
name: invite.user?.name || '',
|
||||
intro: form.intro,
|
||||
anonymous: form.anonymous,
|
||||
responded: !!invite.respondedAt,
|
||||
fields: form.fields.map(f => ({ id: f.id, type: f.type, label: f.label, isRequired: f.isRequired, helpText: f.helpText, options: f.options })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Submit feedback (once per person)
|
||||
// @route POST /api/feedback/public/:token { answers: { fieldId: value } }
|
||||
// @access Public (link holder)
|
||||
const submitPublicForm = async (req, res) => {
|
||||
try {
|
||||
const invite = await findInviteByToken(req.params.token);
|
||||
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
|
||||
if (invite.respondedAt) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
|
||||
const answers = req.body?.answers || {};
|
||||
const rows = [];
|
||||
for (const f of invite.form.fields.filter(x => ANSWERABLE.includes(x.type))) {
|
||||
const value = cleanAnswer(f, answers[f.id]);
|
||||
if (value !== null) rows.push({ fieldId: f.id, value });
|
||||
}
|
||||
if (rows.length === 0) throw feedbackError('Please answer at least one question.');
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Claim the invite first, so a double-submit can't create two responses.
|
||||
const claimed = await tx.feedbackInvite.updateMany({ where: { id: invite.id, respondedAt: null }, data: { respondedAt: new Date() } });
|
||||
if (claimed.count !== 1) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
|
||||
await tx.feedbackResponse.create({
|
||||
data: { formId: invite.formId, userId: invite.form.anonymous ? null : invite.userId, answers: { create: rows } },
|
||||
});
|
||||
});
|
||||
res.status(201).json({ message: 'Thank you for your feedback!' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Feedback summary for one or more events (for the report, and comparisons)
|
||||
// @route GET /api/feedback/summary?eventIds=a,b
|
||||
// @access Private/Supervisor
|
||||
const getSummary = async (req, res) => {
|
||||
try {
|
||||
const ids = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
|
||||
const forms = await prisma.feedbackForm.findMany({
|
||||
where: { eventId: { in: ids } },
|
||||
include: {
|
||||
event: { select: { id: true, title: true, startDate: true } },
|
||||
fields: { orderBy: { order: 'asc' } },
|
||||
responses: { include: { answers: true, user: { select: { name: true } } } },
|
||||
_count: { select: { invites: true } },
|
||||
},
|
||||
});
|
||||
const out = forms.map(form => {
|
||||
const answers = form.responses.flatMap(r => r.answers.map(a => ({ fieldId: a.fieldId, value: a.value, ...(form.anonymous ? {} : { name: r.user?.name || '' }) })));
|
||||
const invited = form._count.invites;
|
||||
return {
|
||||
eventId: form.event.id, title: form.event.title, startDate: form.event.startDate,
|
||||
anonymous: form.anonymous, sentAt: form.sentAt,
|
||||
invited, responses: form.responses.length,
|
||||
responseRate: invited ? Math.round((form.responses.length / invited) * 100) : null,
|
||||
questions: summarise(form.fields, answers),
|
||||
};
|
||||
}).sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary };
|
||||
@@ -0,0 +1,108 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { MAX_MINUTES, impersonationBlocker, logRequest } = require('../utils/impersonation');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
const err = (message, statusCode = 400) => Object.assign(new Error(message), { statusCode });
|
||||
|
||||
// @desc Start viewing the site as a user (read-only, 30 minutes)
|
||||
// @route POST /api/admin/impersonate/:userId { reason }
|
||||
// @access Private/Admin (with 2FA)
|
||||
const startImpersonation = async (req, res) => {
|
||||
try {
|
||||
if (req.readOnly) throw err('End the current impersonation first.', 403);
|
||||
const reason = String(req.body?.reason || '').trim();
|
||||
if (reason.length < 5) throw err('Give a reason (at least a few words) — it is recorded in the audit log.');
|
||||
const target = await prisma.user.findUnique({ where: { id: req.params.userId }, select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true } });
|
||||
const blocker = impersonationBlocker(req.user, target);
|
||||
if (blocker) throw err(blocker, target ? 403 : 404);
|
||||
|
||||
// One live session per admin.
|
||||
await prisma.impersonationSession.updateMany({ where: { adminId: req.user.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'replaced' } });
|
||||
const expiresAt = new Date(Date.now() + MAX_MINUTES * 60 * 1000);
|
||||
const session = await prisma.impersonationSession.create({ data: { adminId: req.user.id, targetUserId: target.id, reason: reason.slice(0, 500), expiresAt } });
|
||||
const token = jwt.sign({ id: target.id, role: target.role, tokenVersion: target.tokenVersion, imp: session.id }, process.env.JWT_SECRET, { expiresIn: `${MAX_MINUTES}m` });
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id,
|
||||
metadata: { sessionId: session.id, reason: session.reason, targetName: target.name, expiresAt }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
// Optional: tell the user (Site Settings → Security).
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
if (getSettingSync('notify_user_on_impersonation', 'false') === 'true' && target.email && !target.email.endsWith('@guest.local')) {
|
||||
const { sendMail } = require('../utils/email');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
sendMail({
|
||||
to: target.email,
|
||||
subject: `An administrator viewed your account — ${orgName}`,
|
||||
text: `Hi ${target.name || 'there'},\n\nAn administrator at ${orgName} viewed your account (read-only) to help with support.\nReason given: ${session.reason}\n\nNothing was changed. If you have questions, reply to this email.\n\n${orgName}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
res.status(201).json({ token, session: { id: session.id, expiresAt, target: { id: target.id, name: target.name, email: target.email, role: target.role } } });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc End impersonation (called with the impersonation token)
|
||||
// @route POST /api/admin/impersonate/end
|
||||
// @access Impersonation token
|
||||
const endImpersonation = async (req, res) => {
|
||||
try {
|
||||
const sessionId = req.impersonation?.id;
|
||||
if (!sessionId) throw err('Not impersonating.', 400);
|
||||
const s = await prisma.impersonationSession.update({ where: { id: sessionId }, data: { endedAt: new Date(), endedReason: 'ended' } });
|
||||
const count = await prisma.impersonationRequestLog.count({ where: { sessionId } });
|
||||
logAdminAction({
|
||||
actorId: s.adminId, actorRole: 'admin', action: 'impersonation_ended', targetType: 'User', targetId: s.targetUserId,
|
||||
metadata: { sessionId, minutes: Math.round((Date.now() - s.startedAt.getTime()) / 60000), requests: count }, ip: getClientIp(req),
|
||||
});
|
||||
res.json({ message: 'Impersonation ended.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Record a page the admin viewed while impersonating
|
||||
// @route POST /api/admin/impersonate/log { path }
|
||||
// @access Impersonation token
|
||||
const logPageView = async (req, res) => {
|
||||
if (req.impersonation?.id) logRequest(req.impersonation.id, 'PAGE', String(req.body?.path || '').slice(0, 500));
|
||||
res.status(204).end();
|
||||
};
|
||||
|
||||
// @desc The current impersonation session (for the banner)
|
||||
// @route GET /api/admin/impersonate/current
|
||||
// @access Impersonation token
|
||||
const currentImpersonation = async (req, res) => {
|
||||
const s = req.impersonation;
|
||||
if (!s) return res.json(null);
|
||||
res.json({ id: s.id, expiresAt: s.expiresAt, target: { id: req.user.id, name: req.user.name, email: req.user.email } });
|
||||
};
|
||||
|
||||
// @desc Impersonation sessions with their request trail (audit log page)
|
||||
// @route GET /api/admin/impersonation-sessions
|
||||
// @access Private/Admin
|
||||
const listSessions = async (req, res) => {
|
||||
try {
|
||||
const sessions = await prisma.impersonationSession.findMany({
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: Math.min(parseInt(req.query.limit, 10) || 50, 200),
|
||||
include: {
|
||||
admin: { select: { name: true, email: true } },
|
||||
target: { select: { name: true, email: true } },
|
||||
requests: { orderBy: { createdAt: 'asc' }, take: 500, select: { method: true, path: true, blocked: true, createdAt: true } },
|
||||
},
|
||||
});
|
||||
res.json(sessions);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions };
|
||||
@@ -6,6 +6,7 @@ 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');
|
||||
@@ -916,25 +917,27 @@ 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.
|
||||
* bundle saving and coupon discount as negative lines. Previous/partial payment lines are
|
||||
* added by callers.
|
||||
*/
|
||||
function checkoutLineItems(registration) {
|
||||
const { buildLineItems, couponDiscountLine } = require('../utils/pdfDocs');
|
||||
const { buildLineItems, discountLines } = 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) } });
|
||||
for (const line of discountLines(registration)) {
|
||||
items.push({ displayName: line.label, quantity: 1, pricingDetails: { price: -Math.round(line.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 } = {}) {
|
||||
// donationAmount (optional) is added on top as its own line; the webhook splits it off into a
|
||||
// separate donation payment using the amount carried in the checkout metadata.
|
||||
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount = 0 } = {}) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
@@ -946,7 +949,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: true
|
||||
}
|
||||
@@ -960,7 +963,8 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
|
||||
if (remainingAmount <= 0) throw new Error('This registration is already paid in full');
|
||||
|
||||
const amountInCents = Math.round(remainingAmount * 100);
|
||||
const donationInCents = Math.round((Number(donationAmount) || 0) * 100);
|
||||
const amountInCents = Math.round(remainingAmount * 100) + donationInCents;
|
||||
const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`;
|
||||
const lineItems = checkoutLineItems(registration);
|
||||
|
||||
@@ -973,11 +977,17 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
});
|
||||
}
|
||||
// Paying full remaining — no "Partial Payment" line needed
|
||||
if (donationInCents > 0) {
|
||||
lineItems.push({ displayName: 'Donation', quantity: 1, pricingDetails: { price: donationInCents } });
|
||||
}
|
||||
|
||||
const checkoutData = {
|
||||
amount: amountInCents,
|
||||
currency: 'ZAR',
|
||||
metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' },
|
||||
metadata: {
|
||||
registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment',
|
||||
...(donationInCents > 0 ? { donationAmount: (donationInCents / 100).toFixed(2) } : {})
|
||||
},
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
failureUrl,
|
||||
@@ -987,7 +997,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({
|
||||
@@ -998,7 +1008,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
return {
|
||||
checkoutId: response.data.id,
|
||||
redirectUrl: response.data.redirectUrl,
|
||||
amount: remainingAmount,
|
||||
amount: amountInCents / 100,
|
||||
registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title }
|
||||
};
|
||||
}
|
||||
@@ -1008,7 +1018,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
// @access Private
|
||||
const createYocoCheckout = async (req, res) => {
|
||||
try {
|
||||
const { registrationId, eventId, amount, successUrl, cancelUrl, failureUrl } = req.body;
|
||||
const { registrationId, eventId, amount, donationAmount, successUrl, cancelUrl, failureUrl } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Validate required parameters for either registration payment or donation
|
||||
@@ -1066,7 +1076,7 @@ const createYocoCheckout = async (req, res) => {
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: true
|
||||
}
|
||||
@@ -1090,12 +1100,20 @@ 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 } });
|
||||
}
|
||||
|
||||
const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl });
|
||||
// Optional donation added at registration ("Donation amount" on the register form)
|
||||
let donation = 0;
|
||||
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
|
||||
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
|
||||
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
|
||||
if (donation > 0 && donation < 15) { res.status(400); throw new Error('Minimum donation is R15'); }
|
||||
}
|
||||
|
||||
const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation });
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
@@ -1151,7 +1169,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({
|
||||
@@ -1163,7 +1181,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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const ACTIVE_COUPONS_INCLUDE = {
|
||||
where: { releasedAt: null },
|
||||
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
|
||||
};
|
||||
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
|
||||
|
||||
/**
|
||||
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
||||
@@ -110,7 +111,7 @@ async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
|
||||
// @access Private
|
||||
const createRegistration = async (req, res) => {
|
||||
try {
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken } = req.body;
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = req.body;
|
||||
|
||||
// Check if event exists
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
@@ -429,9 +430,9 @@ const createRegistration = async (req, res) => {
|
||||
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
||||
}
|
||||
|
||||
// Apply the coupon (or re-work an existing one over the merged items).
|
||||
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
|
||||
let couponError = null;
|
||||
if (!isNewRegistration) await recalculateRegistrationDiscount(registration.id);
|
||||
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
|
||||
if (couponCode) {
|
||||
try {
|
||||
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
|
||||
@@ -441,7 +442,7 @@ const createRegistration = async (req, res) => {
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
if (couponCode || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
|
||||
if (couponCode || hasDiscount || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registration.id },
|
||||
include: {
|
||||
@@ -449,6 +450,8 @@ const createRegistration = async (req, res) => {
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
}
|
||||
});
|
||||
@@ -503,6 +506,12 @@ const createRegistration = async (req, res) => {
|
||||
}
|
||||
})();
|
||||
|
||||
// Funnel: this registration came from a tracked (cookie-consented) visit.
|
||||
if (analyticsSessionId) {
|
||||
const { recordSubmit } = require('../utils/analytics');
|
||||
recordSubmit(registration, analyticsSessionId).catch(e => console.warn('[analytics] submit not recorded:', e?.message || e));
|
||||
}
|
||||
|
||||
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
@@ -516,6 +525,8 @@ const getRegistrations = async (req, res) => {
|
||||
try {
|
||||
const include = {
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
registrationOptions: {
|
||||
@@ -580,7 +591,7 @@ const getUserRegistrations = async (req, res) => {
|
||||
where: { ...whereClause, status: { in: ['pending', 'partial_paid'] } },
|
||||
select: { id: true }
|
||||
});
|
||||
if (staleCandidates.length > 0) {
|
||||
if (staleCandidates.length > 0 && !req.readOnly) {
|
||||
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
|
||||
}
|
||||
|
||||
@@ -595,6 +606,8 @@ const getUserRegistrations = async (req, res) => {
|
||||
}
|
||||
},
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
formResponses: { select: { id: true, attendeeId: true } },
|
||||
// Nest the event's form so the frontend can tell whether attendee forms are
|
||||
@@ -645,6 +658,8 @@ const getRegistrationById = async (req, res) => {
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
formResponses: { include: { answers: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
};
|
||||
|
||||
@@ -657,7 +672,7 @@ const getRegistrationById = async (req, res) => {
|
||||
|
||||
// See getUserRegistrations — keep an unpaid/partially-paid registration's price current
|
||||
// whenever it's viewed, not just at payment time.
|
||||
if (registration.status === 'pending' || registration.status === 'partial_paid') {
|
||||
if ((registration.status === 'pending' || registration.status === 'partial_paid') && !req.readOnly) {
|
||||
await refreshPricingForRegistration(registration.id).catch(() => {});
|
||||
registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
|
||||
}
|
||||
@@ -870,7 +885,7 @@ const getRegistrationsByEvent = async (req, res) => {
|
||||
where: { eventId: req.params.eventId, status: { in: ['pending', 'partial_paid'] } },
|
||||
select: { id: true }
|
||||
});
|
||||
if (staleCandidates.length > 0) {
|
||||
if (staleCandidates.length > 0 && !req.readOnly) {
|
||||
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
|
||||
}
|
||||
|
||||
@@ -888,6 +903,8 @@ const getRegistrationsByEvent = async (req, res) => {
|
||||
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
@@ -1219,6 +1236,8 @@ const createManualRegistration = async (req, res) => {
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
},
|
||||
});
|
||||
registration.status = newExistingStatus;
|
||||
@@ -1256,6 +1275,7 @@ const createManualRegistration = async (req, res) => {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
},
|
||||
});
|
||||
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
|
||||
if (couponCode) {
|
||||
try {
|
||||
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode, appliedById: req.user.id });
|
||||
@@ -1263,6 +1283,8 @@ const createManualRegistration = async (req, res) => {
|
||||
if (!e.isCouponError) throw e;
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
if (couponCode || hasDiscount) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registration.id },
|
||||
include: {
|
||||
@@ -1270,6 +1292,8 @@ const createManualRegistration = async (req, res) => {
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1711,6 +1735,8 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
payments: true,
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
}
|
||||
});
|
||||
@@ -2117,6 +2143,8 @@ const applyCoupon = async (req, res) => {
|
||||
registrationOptions: { include: { eventOption: true, variant: { select: { id: true, name: true, price: true } }, tranches: true } },
|
||||
payments: true,
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
},
|
||||
});
|
||||
res.json({ ...attachComputedTotals(updated), message: `Code ${coupon.code} applied — ${describeCoupon(coupon)}.` });
|
||||
@@ -2220,7 +2248,7 @@ const updateAttendees = async (req, res) => {
|
||||
where: { id: a.id },
|
||||
data: {
|
||||
name: c.name, email: c.email, phone: c.phone,
|
||||
dateOfBirth: c.dateOfBirth, parentName: c.parentName, parentEmail: c.parentEmail, parentPhone: c.parentPhone,
|
||||
dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: c.parentName, parentEmail: c.parentEmail, parentPhone: c.parentPhone,
|
||||
...(contactChanged ? { userId: match?.user?.id || null } : {}),
|
||||
},
|
||||
});
|
||||
@@ -2248,7 +2276,7 @@ const updateAttendees = async (req, res) => {
|
||||
where: { id: registration.id },
|
||||
select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE },
|
||||
});
|
||||
res.json(updated);
|
||||
res.json({ ...updated, attendees: updated.attendees.map(({ userId, ...a }) => a) });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { LOCK_INCLUDE, activeLocks, acquireLock, snapshotFor, syncScans } = require('../utils/scanLocks');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
const audit = (req, action, lock, metadata = {}) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'ScanLock', targetId: lock.id,
|
||||
metadata: { eventId: lock.eventId, sectionId: lock.sectionId, deviceName: lock.deviceName, ...metadata },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const lockView = (l) => ({
|
||||
id: l.id, eventId: l.eventId, sectionId: l.sectionId, sectionName: l.section?.name || null,
|
||||
deviceId: l.deviceId, deviceName: l.deviceName, user: l.user, lockedAt: l.lockedAt,
|
||||
snapshotAt: l.snapshotAt, lastSyncAt: l.lastSyncAt, releasedAt: l.releasedAt,
|
||||
});
|
||||
|
||||
/** The lock, if the caller is the person holding it on this device. */
|
||||
async function ownLock(req, res) {
|
||||
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
|
||||
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
|
||||
const deviceId = req.body?.deviceId || req.query?.deviceId;
|
||||
if (lock.userId !== req.user.id || lock.deviceId !== deviceId) { res.status(403); throw new Error('This offline lock belongs to another device.'); }
|
||||
return lock;
|
||||
}
|
||||
|
||||
// @desc Active offline locks for an event
|
||||
// @route GET /api/scan-locks?eventId=
|
||||
// @access Private/Staff
|
||||
const listLocks = async (req, res) => {
|
||||
try {
|
||||
if (!req.query.eventId) { res.status(400); throw new Error('eventId is required'); }
|
||||
res.json((await activeLocks(String(req.query.eventId))).map(lockView));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Lock a section (or the whole event) to this device for offline scanning
|
||||
// @route POST /api/scan-locks { eventId, sectionId?, deviceId, deviceName? }
|
||||
// @access Private/Staff
|
||||
const createLock = async (req, res) => {
|
||||
try {
|
||||
const { eventId, sectionId, deviceId, deviceName } = req.body || {};
|
||||
if (!eventId) { res.status(400); throw new Error('eventId is required'); }
|
||||
const lock = await acquireLock({ eventId, sectionId: sectionId || null, deviceId, deviceName, userId: req.user.id });
|
||||
audit(req, 'scan_lock_acquired', lock);
|
||||
res.status(201).json(lockView(lock));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Download the tickets valid for this lock, for offline scanning
|
||||
// @route GET /api/scan-locks/:id/snapshot?deviceId=
|
||||
// @access Private/Staff (lock holder)
|
||||
const getSnapshot = async (req, res) => {
|
||||
try {
|
||||
const lock = await ownLock(req, res);
|
||||
if (lock.releasedAt) { res.status(410); throw new Error('This offline lock has been released.'); }
|
||||
res.json({ lock: lockView(lock), ...(await snapshotFor(lock)) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Upload scans made offline
|
||||
// @route POST /api/scan-locks/:id/sync { deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }
|
||||
// @access Private/Staff (lock holder — also after a release, so nothing made offline is lost)
|
||||
const syncLock = async (req, res) => {
|
||||
try {
|
||||
const lock = await ownLock(req, res);
|
||||
const result = await syncScans(lock, req.body?.scans);
|
||||
if (result.accepted || result.conflicts.length) {
|
||||
audit(req, 'offline_sync', lock, { accepted: result.accepted, conflicts: result.conflicts.length, duplicates: result.duplicates, afterForceRelease: !!lock.forceReleasedById });
|
||||
}
|
||||
res.json({ accepted: result.accepted, duplicates: result.duplicates, conflicts: result.conflicts, reportId: result.report.id, released: !!lock.releasedAt });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc End offline mode (the device should sync first)
|
||||
// @route DELETE /api/scan-locks/:id { deviceId }
|
||||
// @access Private/Staff (lock holder)
|
||||
const releaseLock = async (req, res) => {
|
||||
try {
|
||||
const lock = await ownLock(req, res);
|
||||
if (!lock.releasedAt) {
|
||||
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date() } });
|
||||
audit(req, 'scan_lock_released', lock);
|
||||
}
|
||||
res.json({ message: 'Offline mode ended.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Release someone else's lock (e.g. a lost or flat phone)
|
||||
// @route POST /api/scan-locks/:id/force-release
|
||||
// @access Private/Supervisor
|
||||
const forceRelease = async (req, res) => {
|
||||
try {
|
||||
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
|
||||
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
|
||||
if (lock.releasedAt) return res.json({ message: 'Already released.' });
|
||||
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date(), forceReleasedById: req.user.id } });
|
||||
audit(req, 'scan_lock_force_released', lock, { heldBy: lock.user?.name });
|
||||
res.json({ message: `Released. If ${lock.user?.name || 'that device'} reconnects, its offline scans will still sync and be flagged for review.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Offline sync reports for an event (with anything that needs review)
|
||||
// @route GET /api/scan-locks/reports?eventId=&unreviewed=true
|
||||
// @access Private/Supervisor
|
||||
const listReports = async (req, res) => {
|
||||
try {
|
||||
const where = {
|
||||
scanLock: { eventId: String(req.query.eventId || '') },
|
||||
...(req.query.unreviewed === 'true' ? { reviewedAt: null } : {}),
|
||||
};
|
||||
const reports = await prisma.offlineSyncReport.findMany({
|
||||
where,
|
||||
include: { scanLock: { include: LOCK_INCLUDE }, reviewedBy: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
res.json(reports.map(r => ({ ...r, scanLock: lockView(r.scanLock) })));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Mark a sync report as reviewed
|
||||
// @route POST /api/scan-locks/reports/:id/review
|
||||
// @access Private/Supervisor
|
||||
const reviewReport = async (req, res) => {
|
||||
try {
|
||||
const report = await prisma.offlineSyncReport.update({ where: { id: req.params.id }, data: { reviewedAt: new Date(), reviewedById: req.user.id } });
|
||||
res.json(report);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { SUPERVISORS, listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport };
|
||||
@@ -8,6 +8,10 @@ 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
|
||||
@@ -17,6 +21,8 @@ const PUBLIC_KEYS = [
|
||||
// the admin re-saves the Branding tab).
|
||||
'primary_color', 'secondary_color', 'accent_color', 'logo_url', 'favicon_url',
|
||||
'setup_complete', 'app_base_url',
|
||||
// Google Search Console "HTML tag" code, output as a meta tag on every page
|
||||
'google_site_verification',
|
||||
// Legal pages
|
||||
'legal_operator_name', 'legal_io_name', 'legal_io_email',
|
||||
'legal_website_url', 'legal_effective_date',
|
||||
@@ -44,6 +50,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
|
||||
@@ -79,7 +86,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);
|
||||
|
||||
@@ -118,7 +125,7 @@ const updateSettings = async (req, res) => {
|
||||
actorRole: req.user.role,
|
||||
action: 'settings_updated',
|
||||
targetType: 'AppSetting',
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null) },
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !YOCO_PRIVATE_KEYS.has(k)) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
@@ -195,7 +202,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) },
|
||||
|
||||
@@ -95,9 +95,15 @@ const getUserTickets = async (req, res) => {
|
||||
registration: true
|
||||
}
|
||||
},
|
||||
attendee: { select: { id: true, name: true } },
|
||||
attendee: {
|
||||
select: {
|
||||
id: true, name: true,
|
||||
allocations: { where: { set: { showOnTicket: true } }, select: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } },
|
||||
},
|
||||
},
|
||||
user: { select: { id: true, name: true } },
|
||||
event: true,
|
||||
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
|
||||
usages: {
|
||||
include: {
|
||||
scannedBy: {
|
||||
@@ -287,6 +293,9 @@ const getScanPreview = async (req, res) => {
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
|
||||
const transfer = await findTransferByOldCode(req.params.qrCode);
|
||||
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
@@ -351,6 +360,9 @@ const scanTicket = async (req, res) => {
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
|
||||
const transfer = await findTransferByOldCode(req.params.qrCode);
|
||||
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
@@ -372,6 +384,27 @@ const scanTicket = async (req, res) => {
|
||||
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
|
||||
}
|
||||
|
||||
// Section checks used to happen only in the browser; enforce them here too.
|
||||
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
|
||||
if (sectionId) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
|
||||
if (!section || section.eventId !== ticket.eventId) {
|
||||
return res.status(400).json({ message: 'Section not found for this event', ticket });
|
||||
}
|
||||
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
|
||||
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
|
||||
}
|
||||
}
|
||||
|
||||
// A section (or the whole event) locked to another device for offline scanning
|
||||
// can't be scanned online — those scans would be invisible to the offline device.
|
||||
const { blockingLock } = require('../utils/scanLocks');
|
||||
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
|
||||
if (lock) {
|
||||
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
|
||||
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
|
||||
}
|
||||
|
||||
// Compute how many have already been redeemed
|
||||
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
||||
@@ -461,6 +494,7 @@ const getTicketsByEvent = async (req, res) => {
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
attendee: { select: { id: true, name: true, email: true, phone: true } },
|
||||
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true } },
|
||||
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -670,6 +704,207 @@ 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(' · ');
|
||||
}
|
||||
|
||||
// One ticket card on the PDF, matching the printed design in frontend/src/lib/ticketPrint.ts.
|
||||
function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
|
||||
const pad = 12; const radius = 9;
|
||||
const innerW = w - pad * 2;
|
||||
const gray700 = '#374151'; const gray500 = '#6b7280';
|
||||
|
||||
doc.save();
|
||||
doc.roundedRect(x, y + 1.5, w, h, radius).fillOpacity(0.06).fill('#000000');
|
||||
doc.restore();
|
||||
doc.save();
|
||||
doc.roundedRect(x, y, w, h, radius).lineWidth(0.75).fillAndStroke('#ffffff', '#e5e7eb');
|
||||
doc.restore();
|
||||
|
||||
// Event title, with the date on the right of the same line
|
||||
let cy = y + pad;
|
||||
const eventDate = ticket.event.startDate ? new Date(ticket.event.startDate) : null;
|
||||
const dateStr = eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : '';
|
||||
doc.font('Helvetica').fontSize(9);
|
||||
const dateW = dateStr ? doc.widthOfString(dateStr) : 0;
|
||||
if (dateStr) doc.fillColor(gray500).text(dateStr, x + w - pad - dateW, cy + 1, { width: dateW, lineBreak: false });
|
||||
const titleW = innerW - (dateStr ? dateW + 8 : 0);
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#111827');
|
||||
doc.text(ticket.event.title, x + pad, cy, { width: titleW });
|
||||
cy += doc.heightOfString(ticket.event.title, { width: titleW }) + 4;
|
||||
|
||||
// Ticket type
|
||||
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
|
||||
const type = `${ticket.registrationOption.eventOption.name}${variantSuffix}`;
|
||||
doc.font('Helvetica').fontSize(10).fillColor(gray700).text(type, x + pad, cy, { width: innerW });
|
||||
cy += doc.heightOfString(type, { width: innerW }) + 2;
|
||||
|
||||
// Name (named ticket) or quantity, value in bold
|
||||
doc.font('Helvetica').fontSize(9).fillColor(gray700)
|
||||
.text(ticket.attendee ? 'Name: ' : 'Qty: ', x + pad, cy, { continued: true })
|
||||
.font('Helvetica-Bold').text(ticket.attendee ? ticket.attendee.name : String(ticket.quantity || 1));
|
||||
cy += 12;
|
||||
const groupLabel = allocationLabel(ticket.attendee);
|
||||
if (groupLabel) {
|
||||
doc.font('Helvetica').fontSize(9).fillColor(gray500).text(groupLabel, x + pad, cy, { width: innerW });
|
||||
cy += doc.heightOfString(groupLabel, { width: innerW }) + 2;
|
||||
}
|
||||
|
||||
// Footer lines are measured first so the QR code takes the remaining space
|
||||
const footer = [`Ticket ID: ${ticket.id}`, ...(buyerName ? [`Purchased by: ${buyerName}`] : [])];
|
||||
doc.font('Helvetica').fontSize(8.5);
|
||||
const footerH = footer.reduce((sum, line) => sum + doc.heightOfString(line, { width: innerW }) + 2, 0);
|
||||
const qrSpace = (y + h - pad - footerH) - (cy + 6) - 6;
|
||||
const qrSize = Math.max(60, Math.min(130, qrSpace));
|
||||
const qrY = cy + 6 + Math.max(0, (qrSpace - qrSize) / 2);
|
||||
doc.image(qrDataUrl, x + (w - qrSize) / 2, qrY, { width: qrSize, height: qrSize });
|
||||
|
||||
let fy = y + h - pad - footerH;
|
||||
doc.fillColor(gray500);
|
||||
for (const line of footer) {
|
||||
doc.text(line, x + pad, fy, { width: innerW, align: 'center' });
|
||||
fy += doc.heightOfString(line, { width: innerW }) + 2;
|
||||
}
|
||||
doc.fillColor('#000000');
|
||||
}
|
||||
|
||||
// Internal helper used by both emailTickets and sendTicketsTo
|
||||
async function emailTicketsInternal(req, res, userOverride) {
|
||||
const { ticketIds, registrationId } = req.body;
|
||||
@@ -692,7 +927,7 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
const ticketInclude = {
|
||||
event: true,
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
|
||||
attendee: true,
|
||||
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
|
||||
user: { select: { name: true } },
|
||||
usages: true,
|
||||
};
|
||||
@@ -776,31 +1011,22 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 20 });
|
||||
const writeStream = fs.createWriteStream(tempFilePath);
|
||||
doc.pipe(writeStream);
|
||||
const pageWidth = doc.page.width - 40; const pageHeight = doc.page.height - 40;
|
||||
const ticketsPerRow = 2; const ticketsPerColumn = 4;
|
||||
const ticketWidth = pageWidth / ticketsPerRow; const ticketHeight = pageHeight / ticketsPerColumn;
|
||||
// Same card design as the printed tickets (frontend/src/lib/ticketPrint.ts): rounded white
|
||||
// cards with a light border and soft shadow, two columns, three rows per page.
|
||||
const margin = 20; const gap = 12;
|
||||
const ticketsPerRow = 2; const ticketsPerColumn = 3;
|
||||
const ticketWidth = (doc.page.width - margin * 2 - gap * (ticketsPerRow - 1)) / ticketsPerRow;
|
||||
const ticketHeight = (doc.page.height - margin * 2 - gap * (ticketsPerColumn - 1)) / ticketsPerColumn;
|
||||
const qrCodes = await Promise.all(tickets.map(ticket => new Promise((resolve, reject) => {
|
||||
QRCode.toDataURL(ticket.qrCode, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url }));
|
||||
QRCode.toDataURL(ticket.qrCode, { margin: 1, width: 400 }, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url }));
|
||||
})));
|
||||
const qrCodeMap = qrCodes.reduce((map, item) => { map[item.ticketId] = item.qrDataUrl; return map; }, {});
|
||||
let ticketIndex = 0;
|
||||
for (const ticket of tickets) {
|
||||
const row = Math.floor(ticketIndex % ticketsPerColumn);
|
||||
const col = Math.floor((ticketIndex / ticketsPerColumn) % ticketsPerRow);
|
||||
const x = col * ticketWidth + 20; const y = row * ticketHeight + 20;
|
||||
doc.rect(x, y, ticketWidth, ticketHeight).stroke();
|
||||
doc.font('Helvetica-Bold').fontSize(12).text(ticket.event.title, x + 10, y + 10, { width: ticketWidth - 20 });
|
||||
doc.font('Helvetica').fontSize(10);
|
||||
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
|
||||
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
|
||||
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
||||
const eventDate = new Date(ticket.event.startDate);
|
||||
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
|
||||
doc.text(`Purchased by: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
|
||||
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 });
|
||||
doc.fontSize(8).text(`Ticket ID: ${ticket.id}`, x + 10, qrY + qrCodeSize + 5, { width: ticketWidth - 20, align: 'center' });
|
||||
const slot = ticketIndex % (ticketsPerRow * ticketsPerColumn);
|
||||
const x = margin + (slot % ticketsPerRow) * (ticketWidth + gap);
|
||||
const y = margin + Math.floor(slot / ticketsPerRow) * (ticketHeight + gap);
|
||||
drawTicketCard(doc, { x, y, w: ticketWidth, h: ticketHeight }, ticket, qrCodeMap[ticket.id], ticket.user?.name || user.name);
|
||||
ticketIndex++;
|
||||
if (ticketIndex % (ticketsPerRow * ticketsPerColumn) === 0 && ticketIndex < tickets.length) doc.addPage();
|
||||
}
|
||||
@@ -867,5 +1093,11 @@ module.exports = {
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
getScanStats,
|
||||
transferTicketHandler,
|
||||
cancelTransferHandler,
|
||||
getTransferOffer,
|
||||
acceptTransferOffer,
|
||||
declineTransferOffer,
|
||||
getTicketTransfers
|
||||
};
|
||||
@@ -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',
|
||||
@@ -313,11 +314,18 @@ const handlePaymentSucceeded = async (webhookData) => {
|
||||
} catch (e) { /* ignore — payment will fail below if still null */ }
|
||||
}
|
||||
|
||||
// A registration checkout can carry a donation on top (metadata.donationAmount). It's
|
||||
// recorded as its own donation payment so it never counts towards the registration.
|
||||
const grossAmount = amount / 100; // Convert cents to your currency unit
|
||||
const donationPart = registration?.id
|
||||
? Math.min(Math.max(Number(metadata?.donationAmount) || 0, 0), grossAmount)
|
||||
: 0;
|
||||
|
||||
// Create payment record
|
||||
const payment = await prisma.payment.create({
|
||||
data: {
|
||||
id: uuidv4(), // Generate a UUID for the payment
|
||||
amount: amount / 100, // Convert cents to your currency unit
|
||||
amount: Math.round((grossAmount - donationPart) * 100) / 100,
|
||||
method: method.type, // The type of payment from the webhook
|
||||
status: status === 'succeeded' ? 'completed' : status,
|
||||
externalId: yocoPaymentId,
|
||||
@@ -325,7 +333,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: {
|
||||
@@ -351,6 +365,24 @@ const handlePaymentSucceeded = async (webhookData) => {
|
||||
}
|
||||
});
|
||||
|
||||
let donationPayment = null;
|
||||
if (donationPart > 0) {
|
||||
donationPayment = await prisma.payment.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
amount: donationPart,
|
||||
method: method.type,
|
||||
status: status === 'succeeded' ? 'completed' : status,
|
||||
userId: resolvedUserId,
|
||||
recordedById: resolvedUserId,
|
||||
registrationId: null,
|
||||
eventId: registration.eventId,
|
||||
isDonation: true,
|
||||
originalPaymentId: payment.id,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If this payment is for a registration, update the registration status
|
||||
let updatedRegistration = null;
|
||||
let generatedTickets = [];
|
||||
@@ -385,6 +417,7 @@ const handlePaymentSucceeded = async (webhookData) => {
|
||||
|
||||
// Send emails: payment confirmation first, then tickets (guarantees order)
|
||||
const _whPaymentId = payment.id;
|
||||
const _whDonationId = donationPayment?.id;
|
||||
const _whUserId = registration?.userId;
|
||||
const _whRegId = registration?.id;
|
||||
const _whTicketsGenerated = generatedTickets.length > 0;
|
||||
@@ -395,6 +428,14 @@ const handlePaymentSucceeded = async (webhookData) => {
|
||||
} catch (e) {
|
||||
console.error('Failed to send webhook payment emails:', e);
|
||||
}
|
||||
if (_whDonationId) {
|
||||
try {
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
await sendPaymentEmails(_whDonationId);
|
||||
} catch (e) {
|
||||
console.error('Failed to send webhook donation emails:', e);
|
||||
}
|
||||
}
|
||||
if (_whTicketsGenerated && _whUserId && _whRegId) {
|
||||
const mockReq = { user: { id: _whUserId }, body: { registrationId: _whRegId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
|
||||
@@ -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 };
|
||||
+198
-5
@@ -24,13 +24,10 @@ 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 prisma = require('./config/db');
|
||||
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
|
||||
const getRawBody = require('raw-body');
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Initialize Express app
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -103,6 +100,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')) {
|
||||
@@ -145,6 +146,7 @@ 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);
|
||||
@@ -176,9 +178,16 @@ app.use('/api', costRoutes);
|
||||
app.use('/api/cashups', cashupRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/coupons', require('./routes/couponRoutes'));
|
||||
app.use('/api/bundles', require('./routes/bundleRoutes'));
|
||||
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');
|
||||
@@ -383,6 +392,7 @@ app.get('/docs', async (req, res) => {
|
||||
// ── Endpoint data ────────────────────────────────────────────────────────
|
||||
const GROUPS = [
|
||||
{ title: 'Users', base: '/api/users', endpoints: [
|
||||
{ method:'GET', path:'/api/users/activity', auth:'Bearer', desc:'Recent account activity (logins, password changes) for the current user' },
|
||||
{ method:'POST', path:'/api/users', auth:'public', desc:'Register a new account',
|
||||
request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }},
|
||||
responses:[
|
||||
@@ -506,6 +516,14 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Events', base: '/api/events', endpoints: [
|
||||
{ method:'GET', path:'/api/events/admin/all', auth:'admin', desc:'All events, including inactive ones' },
|
||||
{ method:'GET', path:'/api/events/:id/ics', auth:'Public (optional login)', desc:'Download an .ics calendar file for an event' },
|
||||
{ method:'GET', path:'/api/events/:id/attachments', auth:'Public', desc:'List the downloadable attachments of an event' },
|
||||
{ method:'POST', path:'/api/events/:id/attachments', auth:'supervisor+', desc:'Upload an attachment (multipart, field "file")' },
|
||||
{ method:'DELETE', path:'/api/events/:eventId/attachments/:attachmentId', auth:'supervisor+', desc:'Delete an attachment' },
|
||||
{ method:'GET', path:'/api/events/attachments/status', auth:'admin', desc:'Diagnostics for the attachments pipeline' },
|
||||
{ method:'POST', path:'/api/events/attachments/sync', auth:'admin', desc:'Re-sync attachment records with the files on disk' },
|
||||
{ method:'POST', path:'/api/events/options/:id/variants', auth:'supervisor+', desc:'Add a variant (e.g. a size) to an event option' },
|
||||
{ method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.',
|
||||
queryParams:{ limit:'Max results (default 20)' },
|
||||
responses:[
|
||||
@@ -583,6 +601,12 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Registrations', base: '/api/registrations', endpoints: [
|
||||
{ method:'PUT', path:'/api/registrations/:id/options', auth:'Bearer (owner or staff)', desc:'Change the items on a registration (add/remove), within stock and paid-ticket limits' },
|
||||
{ method:'POST', path:'/api/registrations/:id/coupon', auth:'Bearer (owner or staff)', desc:'Apply a coupon code to an existing registration', request:{ body:{ code:'STAFF50' }}},
|
||||
{ method:'DELETE', path:'/api/registrations/:id/coupon', auth:'staff+', desc:'Remove the coupon from a registration (frees the use)' },
|
||||
{ method:'GET', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Saved draft of the attendee forms' },
|
||||
{ method:'PUT', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Save a draft of the attendee forms (not submitted)' },
|
||||
{ method:'PUT', path:'/api/registrations/:id/forms/responses', auth:'supervisor+', desc:'Replace (edit) the submitted form responses of a registration' },
|
||||
{ method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.',
|
||||
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
|
||||
responses:[
|
||||
@@ -634,6 +658,75 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
]},
|
||||
|
||||
{ title: 'Impersonation', base: '/api/admin', endpoints: [
|
||||
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
|
||||
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
|
||||
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
|
||||
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
|
||||
{ method:'GET', path:'/api/admin/impersonate/current', auth:'impersonation token', desc:'The live impersonation session' },
|
||||
{ method:'GET', path:'/api/admin/impersonation-sessions', auth:'admin', desc:'Impersonation sessions with their request trail' },
|
||||
]},
|
||||
{ title: 'Registration funnel', base: '/api/analytics', endpoints: [
|
||||
{ method:'POST', path:'/api/analytics/track', auth:'Public (rate-limited)', desc:'Record a funnel step from a cookie-consented browser (always 204)',
|
||||
request:{ body:{ sessionId:'random-uuid', eventId:'ev-uuid-...', stage:'view', source:'camp-2025' }}},
|
||||
{ method:'GET', path:'/api/analytics/funnel', auth:'supervisor+', desc:'Funnel for ?eventIds=a,b&from=&to= — totals, conversion, daily trend, by source/device/option' },
|
||||
]},
|
||||
{ title: 'Post-event feedback', base: '/api/feedback', endpoints: [
|
||||
{ method:'GET', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'An event\'s feedback form, with invite and response counts' },
|
||||
{ method:'PUT', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Create/update the feedback form (questions lock once answered)',
|
||||
request:{ body:{ sendAt:'2025-07-13T09:00:00.000Z', audience:'checked_in', anonymous:false, intro:'Thanks for coming!', fields:[{ type:'rating', label:'Overall?', isRequired:true }, { type:'multiple_choice', label:'Best part', options:['Worship','Talks'] }] }}},
|
||||
{ method:'DELETE', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Remove the feedback form' },
|
||||
{ method:'POST', path:'/api/feedback/event/:eventId/send-now', auth:'supervisor+', desc:'Send the feedback form now' },
|
||||
{ method:'GET', path:'/api/feedback/public/:token', auth:'Link holder', desc:'The form behind a personal feedback link' },
|
||||
{ method:'POST', path:'/api/feedback/public/:token', auth:'Link holder', desc:'Submit feedback once', request:{ body:{ answers:{ 'field-uuid':'5' } }}},
|
||||
{ method:'GET', path:'/api/feedback/summary', auth:'supervisor+', desc:'Feedback summary for ?eventIds=a,b (response rate, averages, breakdowns, comments)' },
|
||||
]},
|
||||
{ title: 'Sponsor a spot', base: '/api/sponsorships', endpoints: [
|
||||
{ method:'GET', path:'/api/sponsorships/event/:eventId', auth:'Public', desc:'Sponsorship progress for an event (spots funded, goal) — never who was helped',
|
||||
responses:[{ status:200, desc:'Progress', body:{ enabled:true, open:true, spotPrice:450, showProgress:true, goalSpots:20, spotsFunded:12, sponsors:9 }}]},
|
||||
{ method:'POST', path:'/api/sponsorships/checkout', auth:'Public (optional login)', desc:'Start a Yoco checkout to sponsor spots or a custom amount; the money lands in the event sponsorship pool',
|
||||
request:{ body:{ eventId:'ev-uuid-...', spots:2, anonymous:false, sponsorName:'The Smith family', message:'Have a great camp!', name:'Sam Smith', email:'sam@example.com' }},
|
||||
responses:[{ status:200, desc:'Checkout created', body:{ redirectUrl:'https://pay.yoco.com/...', amount:900 }}]},
|
||||
{ method:'GET', path:'/api/sponsorships/event/:eventId/pool', auth:'supervisor+', desc:'Sponsorship pool totals: received, assigned, remaining' },
|
||||
]},
|
||||
{ title: 'Rooms & groups', base: '/api/allocations', endpoints: [
|
||||
{ method:'GET', path:'/api/allocations/event/:eventId', auth:'supervisor+', desc:'Allocation board: sets with groups and assignments, named attendees, possible leaders' },
|
||||
{ method:'POST', path:'/api/allocations/sets', auth:'supervisor+', desc:'Create an allocation set', request:{ body:{ eventId:'ev-uuid-...', name:'Rooms', showOnTicket:true }}},
|
||||
{ method:'PUT', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Rename a set or show it on tickets' },
|
||||
{ method:'DELETE', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Delete a set and its groups' },
|
||||
{ method:'POST', path:'/api/allocations/sets/:id/groups', auth:'supervisor+', desc:'Add a group', request:{ body:{ name:'Cabin 1', capacity:8, gender:'female', leaderUserId:'user-uuid-...' }}},
|
||||
{ method:'PUT', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Edit a group' },
|
||||
{ method:'DELETE', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Delete a group (its people become unassigned)' },
|
||||
{ method:'PUT', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Place (or with groupId null, unplace) an attendee; enforces capacity and gender', request:{ body:{ attendeeId:'att-uuid-...', groupId:'grp-uuid-...' }}},
|
||||
{ method:'DELETE', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Clear every assignment in a set' },
|
||||
{ method:'POST', path:'/api/allocations/sets/:id/auto', auth:'supervisor+', desc:'Auto-allocate everyone not yet placed', request:{ body:{ mode:'together' }}, responses:[{ status:200, desc:'Done', body:{ placed:12, unplaced:1 }}]},
|
||||
{ method:'GET', path:'/api/allocations/sets/:id/lists', auth:'supervisor+', desc:'Per-group lists for printing (?medical=true adds medical details; audited)' },
|
||||
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
|
||||
]},
|
||||
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
|
||||
{ method:'DELETE', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Withdraw a transfer offer that has not been accepted yet' },
|
||||
{ method:'GET', path:'/api/tickets/transfer-offer/:token', auth:'Link holder', desc:'The ticket offered behind a transfer link (status, event, ticket type, sender first name)' },
|
||||
{ method:'POST', path:'/api/tickets/transfer-offer/:token/accept', auth:'Link holder', desc:'Accept the ticket — the transfer happens now (new QR code, ticket sent to the new holder)' },
|
||||
{ method:'POST', path:'/api/tickets/transfer-offer/:token/decline', auth:'Link holder', desc:'Decline the ticket offer' },
|
||||
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
|
||||
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
|
||||
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
|
||||
{ method:'GET', path:'/api/tickets/:id/transfers', auth:'payer, holder or staff+', desc:'Transfer history of a ticket' },
|
||||
]},
|
||||
{ title: 'Offline scanning', base: '/api/scan-locks', endpoints: [
|
||||
{ method:'GET', path:'/api/scan-locks', auth:'staff+', desc:'Active offline locks for an event (?eventId=)' },
|
||||
{ method:'POST', path:'/api/scan-locks', auth:'staff+', desc:'Lock a section (or the whole event) to this device for offline scanning',
|
||||
request:{ body:{ eventId:'ev-uuid-...', sectionId:'sec-uuid-...', deviceId:'device-uuid', deviceName:'Chrome on Android' }},
|
||||
responses:[{ status:201, desc:'Locked', body:{ id:'lock-uuid-...', sectionName:'Main hall' }}, { status:409, desc:'Held elsewhere', body:{ message:'"Main hall" is offline-locked by Ann on Chrome on Android.' }}]},
|
||||
{ method:'GET', path:'/api/scan-locks/:id/snapshot', auth:'staff+', desc:'Tickets valid in the lock scope, for the device to keep offline (lock holder, ?deviceId=)' },
|
||||
{ method:'POST', path:'/api/scan-locks/:id/sync', auth:'staff+', desc:'Upload offline scans (idempotent by clientScanId; capped at what is left on each ticket)',
|
||||
request:{ body:{ deviceId:'device-uuid', scans:[{ clientScanId:'scan-uuid', qrCode:'qr-uuid', qty:1, scannedAt:'2025-07-10T08:15:00.000Z' }] }},
|
||||
responses:[{ status:200, desc:'Synced', body:{ accepted:1, duplicates:0, conflicts:[] }}]},
|
||||
{ method:'DELETE', path:'/api/scan-locks/:id', auth:'staff+', desc:'End offline mode (lock holder)' },
|
||||
{ method:'POST', path:'/api/scan-locks/:id/force-release', auth:'supervisor+', desc:'Release a lost device\'s lock' },
|
||||
{ method:'GET', path:'/api/scan-locks/reports', auth:'supervisor+', desc:'Offline sync reports for an event' },
|
||||
{ method:'POST', path:'/api/scan-locks/reports/:id/review', auth:'supervisor+', desc:'Mark a sync report reviewed' },
|
||||
]},
|
||||
{ title: 'Waitlist', base: '/api/waitlist', endpoints: [
|
||||
{ method:'POST', path:'/api/waitlist', auth:'public', desc:'Join the waitlist for a sold-out line (login needed when the event requires it)',
|
||||
request:{ body:{ eventId:'ev-uuid-...', eventOptionId:'opt-uuid-...', quantity:2 }},
|
||||
@@ -662,7 +755,7 @@ app.get('/docs', async (req, res) => {
|
||||
{ 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:200, desc:'Valid (bundle deals come off first, then the code)', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, bundleDiscount:0, bundles:[], 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',
|
||||
@@ -674,7 +767,21 @@ app.get('/docs', async (req, res) => {
|
||||
{ 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: 'Bundle deals', base: '/api/bundles', endpoints: [
|
||||
{ method:'POST', path:'/api/bundles/quote', auth:'public', desc:'The bundle deals a basket gets, applied automatically as many times as they fit and only when cheaper. Staff can pass forUserId (or null for a walk-in).',
|
||||
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }] }},
|
||||
responses:[{ status:200, desc:'Success', body:{ subtotal:550, bundleDiscount:50, bundles:[{ bundleId:'bnd-uuid-...', name:'Family deal', count:1, discount:50 }], total:500 }}]},
|
||||
{ method:'GET', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'An event\'s bundles with uses and total discount given' },
|
||||
{ method:'POST', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'Create a bundle. kind: exact (items with quantities, price for the set) | mix (any setSize of the items for price) | free (any setSize, cheapest freeCount free) | group (setSize or more of the items: groupPricing per_ticket price each, or percent off). items: [{ eventOptionId, variantId?, quantity }] (variantId left out = any variant; quantity only for exact).',
|
||||
request:{ body:{ name:'Family deal', price:500, items:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }], validUntil:null }}},
|
||||
{ method:'PUT', path:'/api/bundles/:id', auth:'supervisor+', desc:'Edit a bundle or switch it off ({ isActive:false }). Registrations keep a bundle they already have.' },
|
||||
{ method:'DELETE', path:'/api/bundles/:id', auth:'supervisor+', desc:'Delete a bundle; one that has been used is switched off instead' },
|
||||
]},
|
||||
{ title: 'Payments', base: '/api/payments', endpoints: [
|
||||
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
|
||||
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
|
||||
{ method:'POST', path:'/api/payments/yoco-checkout/send', auth:'supervisor+', desc:'Send a generated Yoco payment link to the registrant by email or WhatsApp', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
|
||||
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
|
||||
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
|
||||
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
|
||||
responses:[
|
||||
@@ -714,6 +821,10 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Tickets', base: '/api/tickets', endpoints: [
|
||||
{ method:'GET', path:'/api/tickets', auth:'supervisor+', desc:'All tickets' },
|
||||
{ method:'GET', path:'/api/tickets/:id', auth:'Bearer (owner or staff)', desc:'A ticket by id' },
|
||||
{ method:'GET', path:'/api/tickets/qr/:qrCode', auth:'staff+', desc:'A ticket by QR code' },
|
||||
{ method:'PUT', path:'/api/tickets/email-sent', auth:'admin', desc:'Mark tickets as emailed' },
|
||||
{ method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]},
|
||||
{ method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)',
|
||||
@@ -793,6 +904,8 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Reports', base: '/api/reports', endpoints: [
|
||||
{ method:'POST', path:'/api/reports/excel', auth:'Bearer', desc:'Build an Excel file from a report payload' },
|
||||
{ method:'POST', path:'/api/reports/whatsapp', auth:'Bearer', desc:'Send a report PDF by WhatsApp' },
|
||||
{ method:'POST', path:'/api/reports/pdf', auth:'user+', desc:'Generate a PDF report of own registrations and tickets and return it as a binary download',
|
||||
responses:[{ status:200, desc:'PDF file (application/pdf)', body:'<binary PDF>' }]},
|
||||
{ method:'POST', path:'/api/reports/email', auth:'user+', desc:'Generate PDF report and email it to the authenticated user',
|
||||
@@ -817,6 +930,8 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Cashups', base: '/api/cashups', endpoints: [
|
||||
{ method:'GET', path:'/api/cashups/event/:eventId/cash-by-user', auth:'supervisor+', desc:'Payments recorded for an event, by staff member and method' },
|
||||
{ method:'PUT', path:'/api/cashups/event/:eventId/person-cash/:userId', auth:'supervisor+', desc:'Enter one staff member\'s physical cash count for an event' },
|
||||
{ method:'GET', path:'/api/cashups/event/:eventId', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history',
|
||||
pathParams:{ ':eventId':'Event UUID' },
|
||||
responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]},
|
||||
@@ -838,6 +953,8 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Uploads', base: '/api/uploads', endpoints: [
|
||||
{ method:'POST', path:'/api/uploads/logo', auth:'admin (open during first-run setup)', desc:'Upload the site logo (multipart, field "image")' },
|
||||
{ method:'POST', path:'/api/uploads/favicon', auth:'admin (open during first-run setup)', desc:'Upload the site favicon (multipart, field "image")' },
|
||||
{ method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.',
|
||||
notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.',
|
||||
responses:[
|
||||
@@ -894,6 +1011,10 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
|
||||
{ method:'POST', path:'/api/whatsapp/start', auth:'admin', desc:'Start (or restart) the WAWP WhatsApp instance' },
|
||||
{ method:'POST', path:'/api/whatsapp/request-code', auth:'admin', desc:'Request a pairing code to link a WhatsApp number' },
|
||||
{ method:'POST', path:'/api/whatsapp/delete-instance', auth:'admin', desc:'Delete the WAWP instance' },
|
||||
{ method:'POST', path:'/api/whatsapp/webhook', auth:'—', desc:'WAWP event webhook (alias of /api/webhooks/whatsapp)' },
|
||||
{ method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)',
|
||||
responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]},
|
||||
{ method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.',
|
||||
@@ -914,6 +1035,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.',
|
||||
@@ -923,6 +1055,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' }}]},
|
||||
@@ -1343,6 +1490,52 @@ app.listen(PORT, () => {
|
||||
console.warn('[waitlist] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Funnel analytics — once a day, roll raw rows older than 90 days into daily totals.
|
||||
try {
|
||||
const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { rollupOldEvents } = require('./utils/analytics');
|
||||
const run = async () => {
|
||||
try {
|
||||
const n = await rollupOldEvents();
|
||||
if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`);
|
||||
} catch (e) {
|
||||
console.warn('[analytics] Rollup failed:', e?.message || e);
|
||||
}
|
||||
};
|
||||
setTimeout(run, 5 * 60 * 1000);
|
||||
setInterval(run, 24 * 60 * 60 * 1000);
|
||||
console.log('[analytics] Rollup daily.');
|
||||
} else {
|
||||
console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[analytics] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Feedback job — send post-event feedback forms at their send time, and the one
|
||||
// reminder 3 days later.
|
||||
try {
|
||||
const enabled = String(process.env.FEEDBACK_JOB_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runFeedbackJob } = require('./utils/feedback');
|
||||
const everyMs = parseInt(process.env.FEEDBACK_JOB_INTERVAL_MS || String(15 * 60 * 1000), 10);
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await runFeedbackJob();
|
||||
if (r.sent || r.reminded) console.log(`[feedback] ${r.sent} invite(s), ${r.reminded} reminder(s) sent.`);
|
||||
} catch (e) {
|
||||
console.warn('[feedback] Job failed:', e?.message || e);
|
||||
}
|
||||
}, everyMs);
|
||||
console.log(`[feedback] Job every ${Math.round(everyMs / 60000)} min.`);
|
||||
} else {
|
||||
console.log('[feedback] Job disabled by env FEEDBACK_JOB_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[feedback] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily parental-consent job — reminders for unanswered requests, and purging
|
||||
// medical/emergency details a set number of days after each event.
|
||||
try {
|
||||
|
||||
@@ -48,6 +48,17 @@ const protect = async (req, res, next) => {
|
||||
return next(new Error('User account is deactivated'));
|
||||
}
|
||||
|
||||
// An admin viewing as this user: only while the session is live, and read-only.
|
||||
if (decoded.imp) {
|
||||
const { liveSession } = require('../utils/impersonation');
|
||||
const session = await liveSession(String(decoded.imp));
|
||||
if (!session || session.targetUserId !== req.user.id) {
|
||||
return res.status(401).json({ message: 'The impersonation session has ended.', code: 'IMPERSONATION_ENDED' });
|
||||
}
|
||||
req.impersonation = session;
|
||||
req.readOnly = true;
|
||||
}
|
||||
|
||||
// Revocation check — tokenVersion in JWT must match DB
|
||||
// Old tokens without tokenVersion are treated as version 0
|
||||
const tokenVer = decoded.tokenVersion ?? 0;
|
||||
@@ -60,7 +71,8 @@ const protect = async (req, res, next) => {
|
||||
// requirement (or the user's promotion) is sent back to log in, where they
|
||||
// enrol. The setup wizard's own token gets a short grace period.
|
||||
const inSetupGrace = decoded.setup === true && Date.now() / 1000 - (decoded.iat || 0) < SETUP_TOKEN_GRACE_SECONDS;
|
||||
if (!inSetupGrace && (await mustEnrolTwoFactor(req.user))) {
|
||||
// (An impersonating admin has already passed 2FA themselves.)
|
||||
if (!inSetupGrace && !req.impersonation && (await mustEnrolTwoFactor(req.user))) {
|
||||
return res.status(401).json({
|
||||
message: 'Two-factor authentication is required for your role. Please log in again to set it up.',
|
||||
code: 'TWO_FACTOR_REQUIRED',
|
||||
@@ -128,7 +140,17 @@ const optionalAuth = async (req, res, next) => {
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
|
||||
});
|
||||
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
|
||||
req.user = user;
|
||||
if (decoded.imp) {
|
||||
const { liveSession } = require('../utils/impersonation');
|
||||
const session = await liveSession(String(decoded.imp));
|
||||
req.readOnly = true;
|
||||
if (session && session.targetUserId === user.id) {
|
||||
req.user = user;
|
||||
req.impersonation = session;
|
||||
}
|
||||
} else {
|
||||
req.user = user;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Token invalid or expired — proceed without user
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listAuditLog } = require('../controllers/adminAuditController');
|
||||
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/audit-log', protect, admin, listAuditLog);
|
||||
router.get('/impersonation-sessions', protect, admin, listSessions);
|
||||
// Called with the impersonation token itself (which belongs to the viewed user).
|
||||
router.post('/impersonate/end', protect, endImpersonation);
|
||||
router.post('/impersonate/log', protect, logPageView);
|
||||
router.get('/impersonate/current', protect, currentImpersonation);
|
||||
router.post('/impersonate/:userId', protect, admin, startImpersonation);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -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,14 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles } = require('../controllers/bundleController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// What a basket gets off before registering (guests included)
|
||||
router.post('/quote', optionalAuth, quoteBundles);
|
||||
|
||||
router.get('/event/:eventId', protect, supervisor, listBundles);
|
||||
router.post('/event/:eventId', protect, supervisor, createBundle);
|
||||
router.put('/:id', protect, supervisor, updateBundle);
|
||||
router.delete('/:id', protect, supervisor, deleteBundle);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
|
||||
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, resolvePending, getCashupAudit } = require('../controllers/cashupController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/audit', protect, supervisor, getCashupAudit);
|
||||
@@ -10,5 +10,6 @@ router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePerso
|
||||
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
|
||||
router.post('/event/:eventId/close', protect, admin, closeEvent);
|
||||
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
|
||||
router.post('/lines/:lineId/pending', protect, admin, resolvePending);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -33,4 +33,33 @@ async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
|
||||
return { phone, emailUser, phoneUser, conflict, user: conflict ? null : (emailUser || phoneUser || null) };
|
||||
}
|
||||
|
||||
module.exports = { findAccountByContact, hasValidEmail };
|
||||
/**
|
||||
* The account for these contact details, or a new inactive one (same as guest
|
||||
* registration: a placeholder email for phone-only people, a random password, and an
|
||||
* activation link sent by the caller when `created` is true).
|
||||
*/
|
||||
async function findOrCreateAccount({ name, email, phone }, db = prisma) {
|
||||
const match = await findAccountByContact({ email, phone }, db);
|
||||
// Email and phone pointing at two different accounts: go with the email's account,
|
||||
// without saying so — the person typing the details mustn't learn who has an account.
|
||||
if (match.conflict) return { user: match.emailUser, created: false };
|
||||
if (match.user) return { user: match.user, created: false };
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { hashPassword } = require('../config/auth');
|
||||
const validEmail = hasValidEmail(email);
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
email: validEmail ? String(email).trim() : `guest+${uuidv4().slice(0, 8)}@guest.local`,
|
||||
password: await hashPassword(uuidv4()),
|
||||
phoneNumber: match.phone || null,
|
||||
isActive: false,
|
||||
notificationPreference: validEmail && match.phone ? 'both' : validEmail ? 'email' : 'whatsapp',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { user, created: true };
|
||||
}
|
||||
|
||||
module.exports = { findAccountByContact, findOrCreateAccount, hasValidEmail };
|
||||
|
||||
@@ -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 };
|
||||
@@ -69,6 +69,13 @@ function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
|
||||
} else if (consent) {
|
||||
throw attendeeError(`${name}: enter a date of birth.`);
|
||||
}
|
||||
let gender = null;
|
||||
if (a.gender) {
|
||||
gender = String(a.gender).trim().toLowerCase();
|
||||
if (!['male', 'female'].includes(gender)) throw attendeeError(`${name}: gender must be male or female.`);
|
||||
} else if (event.collectGender) {
|
||||
throw attendeeError(`${name}: choose a gender.`);
|
||||
}
|
||||
const parentName = String(a.parentName || '').trim() || null;
|
||||
const parentEmail = String(a.parentEmail || '').trim() || null;
|
||||
const parentPhone = String(a.parentPhone || '').trim() || null;
|
||||
@@ -78,7 +85,7 @@ function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
|
||||
}
|
||||
if (!EMAIL_RE.test(parentEmail)) throw attendeeError(`${name}: "${parentEmail}" isn't a valid parent email address.`);
|
||||
}
|
||||
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone };
|
||||
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone };
|
||||
});
|
||||
|
||||
// Youth events need every main place named (with a date of birth).
|
||||
@@ -187,6 +194,7 @@ async function createAttendees(registrationId, attendees, db = prisma) {
|
||||
email: a.email,
|
||||
phone: a.phone,
|
||||
dateOfBirth: a.dateOfBirth || null,
|
||||
gender: a.gender || null,
|
||||
parentName: a.parentName || null,
|
||||
parentEmail: a.parentEmail || null,
|
||||
parentPhone: a.parentPhone || null,
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Bundle deals — tickets sold together at a better price. Four kinds:
|
||||
* - exact: these tickets in these numbers for a set price, e.g. 5 Adult + 3 Child for R500.
|
||||
* - mix: any `setSize` of the qualifying tickets for a set price, e.g. any 4 for R250.
|
||||
* - free: any `setSize` qualifying tickets with the cheapest `freeCount` free, e.g.
|
||||
* buy 3 get 1 free (setSize 4, freeCount 1).
|
||||
* - group: `setSize` or more qualifying tickets, all of them at `price` each
|
||||
* (groupPricing per_ticket) or `price`% off (percent).
|
||||
*
|
||||
* Rules:
|
||||
* - Applied automatically: no code needed. exact, mix and free apply as many times as
|
||||
* the registration's items allow; leftover items are charged as usual. A group
|
||||
* discount applies once, to every qualifying ticket.
|
||||
* - Best price wins: a bundle only counts when it beats what its items cost at the
|
||||
* prices actually charged (early-bird, staff price), so it never makes anything
|
||||
* dearer. A group discount never raises a ticket's price. The most expensive matching
|
||||
* units go into a bundle first, and when several bundles compete the one saving the
|
||||
* most is applied first.
|
||||
* - A bundle item with no variant matches any variant of its option.
|
||||
* - Coupons are worked out after bundles, on what's left: bundled units are passed on
|
||||
* to the coupon code at their share of the bundle price (see utils/coupons.js).
|
||||
* - The discount is stored on Registration.bundleDiscount (and included in
|
||||
* discountTotal), with one RegistrationBundle row per bundle used.
|
||||
* - A registration keeps any bundle it already has, even after the bundle is switched
|
||||
* off or its dates pass, so editing a registration never takes a deal away.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
|
||||
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
|
||||
|
||||
const KINDS = ['exact', 'mix', 'free', 'group'];
|
||||
|
||||
/** Whether a bundle's settings make sense (bad rows are skipped, never applied). */
|
||||
function isBundleUsable(bundle) {
|
||||
const items = (bundle?.items || []).filter(i => i && i.eventOptionId);
|
||||
if (items.length === 0 || !(Number(bundle.price) >= 0)) return false;
|
||||
const kind = bundle.kind || 'exact';
|
||||
const size = Number(bundle.setSize);
|
||||
if (kind === 'exact') return items.some(i => Number(i.quantity) > 0);
|
||||
if (!Number.isInteger(size) || size < 1) return false;
|
||||
if (kind === 'free') return Number.isInteger(Number(bundle.freeCount)) && bundle.freeCount >= 1 && bundle.freeCount < size;
|
||||
if (kind === 'group' && bundle.groupPricing === 'percent') return Number(bundle.price) > 0 && Number(bundle.price) <= 100;
|
||||
return KINDS.includes(kind);
|
||||
}
|
||||
|
||||
/** Whether a bundle can be offered right now (switched on, within its dates, set up properly). */
|
||||
function isBundleAvailable(bundle, now = new Date()) {
|
||||
if (!bundle || !bundle.isActive) return false;
|
||||
if (bundle.validFrom && now < new Date(bundle.validFrom)) return false;
|
||||
if (bundle.validUntil && now > new Date(bundle.validUntil)) return false;
|
||||
return isBundleUsable(bundle);
|
||||
}
|
||||
|
||||
const itemMatches = (item, line) => item.eventOptionId === line.eventOptionId && (!item.variantId || item.variantId === line.variantId);
|
||||
const qualifies = (bundle, line) => (bundle.items || []).some(i => itemMatches(i, line));
|
||||
|
||||
/** Takes up to `need` units from matching pool lines, dearest first. Mutates `left`. */
|
||||
function takeDearest(pool, left, need, matches) {
|
||||
const takes = [];
|
||||
const candidates = pool
|
||||
.map((p, index) => ({ p, index }))
|
||||
.filter(({ p, index }) => left[index] > 0 && matches(p.line))
|
||||
.sort((a, b) => b.p.line.unitPrice - a.p.line.unitPrice);
|
||||
for (const { p, index } of candidates) {
|
||||
if (need === 0) break;
|
||||
const q = Math.min(need, left[index]);
|
||||
left[index] -= q;
|
||||
need -= q;
|
||||
takes.push({ index, quantity: q, unitPrice: p.line.unitPrice });
|
||||
}
|
||||
return need > 0 ? null : takes;
|
||||
}
|
||||
|
||||
// Every unit at the same share of a set price.
|
||||
const atSetPrice = (takes, price) => {
|
||||
const charged = takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0);
|
||||
const ratio = charged > 0 ? price / charged : 0;
|
||||
return { charged, takes: takes.map(t => ({ ...t, newPrice: t.unitPrice * ratio })) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Works out one application of `bundle` against what's left in `pool`, without changing
|
||||
* it. Returns { takes: [{ index, quantity, newPrice }], saving } or null when the
|
||||
* bundle can't apply.
|
||||
*/
|
||||
function tryBundle(bundle, pool) {
|
||||
const left = pool.map(p => p.remaining);
|
||||
const kind = bundle.kind || 'exact';
|
||||
const price = Number(bundle.price || 0);
|
||||
let priced;
|
||||
|
||||
if (kind === 'exact') {
|
||||
// Variant-specific items first, so an "any variant" item can't use up the units a
|
||||
// specific one needs.
|
||||
const items = [...bundle.items].filter(i => Number(i.quantity) > 0).sort((a, b) => (b.variantId ? 1 : 0) - (a.variantId ? 1 : 0));
|
||||
const takes = [];
|
||||
for (const item of items) {
|
||||
const got = takeDearest(pool, left, Number(item.quantity), line => itemMatches(item, line));
|
||||
if (!got) return null;
|
||||
takes.push(...got);
|
||||
}
|
||||
priced = atSetPrice(takes, price);
|
||||
} else if (kind === 'mix' || kind === 'free') {
|
||||
const takes = takeDearest(pool, left, Number(bundle.setSize), line => qualifies(bundle, line));
|
||||
if (!takes) return null;
|
||||
if (kind === 'mix') {
|
||||
priced = atSetPrice(takes, price);
|
||||
} else {
|
||||
// The cheapest freeCount units cost nothing; split takes so only those are free.
|
||||
let free = Number(bundle.freeCount);
|
||||
const out = [];
|
||||
for (const t of [...takes].sort((a, b) => a.unitPrice - b.unitPrice)) {
|
||||
const f = Math.min(free, t.quantity);
|
||||
free -= f;
|
||||
if (f > 0) out.push({ ...t, quantity: f, newPrice: 0 });
|
||||
if (t.quantity - f > 0) out.push({ ...t, quantity: t.quantity - f, newPrice: t.unitPrice });
|
||||
}
|
||||
priced = { charged: takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0), takes: out };
|
||||
}
|
||||
} else if (kind === 'group') {
|
||||
const total = pool.reduce((s, p, i) => s + (qualifies(bundle, p.line) ? left[i] : 0), 0);
|
||||
if (total < Number(bundle.setSize)) return null;
|
||||
const takes = takeDearest(pool, left, total, line => qualifies(bundle, line));
|
||||
const pct = Math.min(100, Math.max(0, price)) / 100;
|
||||
priced = {
|
||||
charged: takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0),
|
||||
takes: takes.map(t => ({ ...t, newPrice: bundle.groupPricing === 'percent' ? t.unitPrice * (1 - pct) : Math.min(t.unitPrice, price) })),
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const after = priced.takes.reduce((s, t) => s + t.quantity * t.newPrice, 0);
|
||||
return { takes: priced.takes, saving: priced.charged - after };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies bundles to coupon-style lines (see coupons.js buildCouponLines). Pure.
|
||||
*
|
||||
* @param {Array} bundles - [{ id, name, kind, price, setSize, freeCount, groupPricing,
|
||||
* items: [{ eventOptionId, variantId?, quantity }] }]
|
||||
* already filtered to the ones this registration may use
|
||||
* @param {Array} lines - [{ eventOptionId, variantId, quantity, unitPrice, basePrice }]
|
||||
* @returns {{ discount: number, applications: Array<{ bundleId, name, count, discount }>, lines: Array }}
|
||||
* `lines` are the input lines with bundled units split out at their bundle
|
||||
* price (flagged `bundled: true`), ready for the coupon calculation.
|
||||
*/
|
||||
function applyBundles(bundles, lines) {
|
||||
const usable = (bundles || []).filter(isBundleUsable);
|
||||
const pool = (lines || []).map(line => ({ line, remaining: Number(line.quantity || 0), bundled: [] }));
|
||||
const byBundle = new Map();
|
||||
|
||||
// Each round applies the single best-saving bundle once; stops when nothing saves.
|
||||
const maxRounds = pool.reduce((s, p) => s + p.remaining, 0);
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
let best = null;
|
||||
for (const bundle of usable) {
|
||||
// A group discount covers every qualifying ticket in one go, so it applies once.
|
||||
if (bundle.kind === 'group' && byBundle.has(bundle.id)) continue;
|
||||
const attempt = tryBundle(bundle, pool);
|
||||
if (attempt && attempt.saving > 0.005 && (!best || attempt.saving > best.attempt.saving + 0.0001)) best = { bundle, attempt };
|
||||
}
|
||||
if (!best) break;
|
||||
const { bundle, attempt } = best;
|
||||
for (const t of attempt.takes) {
|
||||
pool[t.index].remaining -= t.quantity;
|
||||
pool[t.index].bundled.push({ quantity: t.quantity, unitPrice: t.newPrice });
|
||||
}
|
||||
const entry = byBundle.get(bundle.id) || { bundleId: bundle.id, name: bundle.name, count: 0, discount: 0 };
|
||||
entry.count += 1;
|
||||
entry.discount += attempt.saving;
|
||||
byBundle.set(bundle.id, entry);
|
||||
}
|
||||
|
||||
const applications = [...byBundle.values()].map(a => ({ ...a, discount: round2(a.discount) }));
|
||||
const outLines = [];
|
||||
for (const p of pool) {
|
||||
if (p.remaining > 0) outLines.push({ ...p.line, quantity: p.remaining });
|
||||
for (const b of p.bundled) {
|
||||
outLines.push({ ...p.line, quantity: b.quantity, unitPrice: b.unitPrice, basePrice: b.unitPrice, bundled: true });
|
||||
}
|
||||
}
|
||||
return { discount: round2(applications.reduce((s, a) => s + a.discount, 0)), applications, lines: outLines };
|
||||
}
|
||||
|
||||
const itemLabel = (i) => `${i.eventOption?.name || 'ticket'}${i.variant?.name ? ` (${i.variant.name})` : ''}`;
|
||||
|
||||
/** "5 × Adult + 3 × Child (Large)", or for the other kinds "Adult, Child". Needs names loaded. */
|
||||
function describeBundleItems(bundle) {
|
||||
const items = bundle?.items || [];
|
||||
if ((bundle?.kind || 'exact') === 'exact') return items.map(i => `${i.quantity} × ${itemLabel(i)}`).join(' + ');
|
||||
return items.map(itemLabel).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The deal in one line, e.g. "2 × Adult + 3 × Child for R220", "Any 4 of Adult, Child
|
||||
* for R250", "Buy 3, get 1 free: Adult, Child", "10+ tickets (Adult, Child): R70 each".
|
||||
*/
|
||||
function describeBundle(bundle) {
|
||||
const kind = bundle?.kind || 'exact';
|
||||
const items = describeBundleItems(bundle);
|
||||
const price = Number(bundle?.price || 0);
|
||||
if (kind === 'mix') return `Any ${bundle.setSize} of ${items} for ${fmtR(price)}`;
|
||||
if (kind === 'free') return `Buy ${bundle.setSize - bundle.freeCount}, get ${bundle.freeCount} free: ${items}`;
|
||||
if (kind === 'group') {
|
||||
return `${bundle.setSize}+ tickets (${items}): ${bundle.groupPricing === 'percent' ? `${price}% off` : `${fmtR(price)} each`}`;
|
||||
}
|
||||
return `${items} for ${fmtR(price)}`;
|
||||
}
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const BUNDLE_INCLUDE = {
|
||||
items: { include: { eventOption: { select: { id: true, name: true } }, variant: { select: { id: true, name: true } } } },
|
||||
};
|
||||
|
||||
// A registration's bundle deals, for API responses and receipts.
|
||||
const BUNDLE_APPS_INCLUDE = { select: { id: true, bundleId: true, count: true, discount: true, bundle: { select: { id: true, name: true } } } };
|
||||
|
||||
/**
|
||||
* Bundles a registration on this event may use: the ones available now, plus any in
|
||||
* `keepIds` (bundles the registration already has).
|
||||
*/
|
||||
async function loadBundlesFor(eventId, { keepIds = [], now = new Date() } = {}, db = prisma) {
|
||||
if (!eventId) return [];
|
||||
const bundles = await db.eventBundle.findMany({ where: { eventId }, include: BUNDLE_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
|
||||
return bundles.filter(b => isBundleAvailable(b, now) || (keepIds.includes(b.id) && isBundleUsable(b)));
|
||||
}
|
||||
|
||||
/** Replaces a registration's RegistrationBundle rows when the applications changed. */
|
||||
async function saveBundleApplications(registrationId, existing, applications, db = prisma) {
|
||||
const key = (list) => JSON.stringify([...list].map(a => [a.bundleId, a.count, round2(a.discount)]).sort());
|
||||
if (key(existing || []) === key(applications)) return;
|
||||
await db.registrationBundle.deleteMany({ where: { registrationId } });
|
||||
if (applications.length > 0) {
|
||||
await db.registrationBundle.createMany({
|
||||
data: applications.map(a => ({ registrationId, bundleId: a.bundleId, count: a.count, discount: a.discount })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KINDS,
|
||||
isBundleUsable,
|
||||
isBundleAvailable,
|
||||
applyBundles,
|
||||
describeBundleItems,
|
||||
describeBundle,
|
||||
loadBundlesFor,
|
||||
saveBundleApplications,
|
||||
BUNDLE_INCLUDE,
|
||||
BUNDLE_APPS_INCLUDE,
|
||||
};
|
||||
@@ -6,10 +6,114 @@ const ALL_METHODS = ['cash', 'card', 'eft', 'other'];
|
||||
// Standard South African Rand note/coin denominations used for the cash count grid.
|
||||
const ZAR_DENOMINATIONS = [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1];
|
||||
|
||||
const WRITE_OFF_CATEGORIES = ['card_fees', 'bank_charges', 'cash_shortfall', 'other'];
|
||||
const PENDING_OUTCOMES = ['received', 'written_off'];
|
||||
|
||||
function emptyByMethod(fill = 0) {
|
||||
return { cash: fill, card: fill, eft: fill, other: fill };
|
||||
}
|
||||
|
||||
const cents = (n) => Math.round(n * 100) / 100;
|
||||
const sum = (rows) => rows.reduce((s, r) => s + r.amount, 0);
|
||||
|
||||
// A non-negative amount rounded to cents, or null when left blank.
|
||||
function amountOrNull(value, label) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) throw new Error(`${label} must be zero or more`);
|
||||
return cents(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where one method's expected takings ended up, as entered on the cashup screen. Pure.
|
||||
*
|
||||
* actual = in bank + on hand + pending (money held or on its way); write-offs explain money
|
||||
* that's gone (card fees, bank charges, a cash shortfall). Whatever is left —
|
||||
* expected − actual − write-offs — is unaccounted for. Cash has no pending; when staff
|
||||
* counted it (per-person counts), the count is all the cash there is, so whatever wasn't
|
||||
* deposited (or taken by the bank as a deposit fee) is on hand.
|
||||
*
|
||||
* @param {object} p
|
||||
* @param {string} p.method - 'cash' | 'card' | 'eft' | 'other'
|
||||
* @param {number} p.expected - expected amount for the method (payments − costs paid from it)
|
||||
* @param {number|null} [p.counted] - cash counted by staff, or null when nobody counted
|
||||
* @param {object} [p.input] - { bankAmount, onHandAmount, pendingAmount, pendingReference, writeOffs: [{ category, amount, reason }] }
|
||||
*/
|
||||
function settleLine({ method, expected, counted = null, input = {} }) {
|
||||
const bankAmount = amountOrNull(input.bankAmount, 'In bank');
|
||||
let onHandAmount = method === 'cash' ? amountOrNull(input.onHandAmount, 'Cash on hand') : null;
|
||||
const pendingAmount = method === 'cash' ? null : amountOrNull(input.pendingAmount, 'Pending');
|
||||
|
||||
const writeOffs = (Array.isArray(input.writeOffs) ? input.writeOffs : [])
|
||||
.filter(w => w && w.amount !== undefined && w.amount !== null && w.amount !== '')
|
||||
.map(w => {
|
||||
if (!WRITE_OFF_CATEGORIES.includes(w.category)) throw new Error('Choose what each write-off is for');
|
||||
const amount = amountOrNull(w.amount, 'Write-off');
|
||||
if (!(amount > 0)) throw new Error('A write-off must be more than R0');
|
||||
return { category: w.category, amount, reason: String(w.reason || '').trim().slice(0, 200) || null };
|
||||
});
|
||||
const writeOffTotal = cents(sum(writeOffs));
|
||||
|
||||
if (method === 'cash' && counted !== null && counted !== undefined) {
|
||||
// A deposit fee comes out of cash that was counted and banked, so the count is what
|
||||
// reached the bank + the fee + what's still on hand.
|
||||
const depositFees = cents(sum(writeOffs.filter(w => w.category === 'bank_charges')));
|
||||
if ((bankAmount || 0) + depositFees > counted + 0.005) throw new Error('More cash was deposited than was counted');
|
||||
onHandAmount = cents(counted - (bankAmount || 0) - depositFees);
|
||||
}
|
||||
|
||||
const parts = [bankAmount, onHandAmount, pendingAmount];
|
||||
const entered = parts.some(v => v !== null) || writeOffs.length > 0;
|
||||
const actual = entered ? cents(parts.reduce((s, v) => s + (v || 0), 0)) : null;
|
||||
|
||||
return {
|
||||
bankAmount,
|
||||
onHandAmount,
|
||||
pendingAmount,
|
||||
pendingReference: pendingAmount ? (String(input.pendingReference || '').trim().slice(0, 200) || null) : null,
|
||||
writeOffs,
|
||||
writeOffTotal,
|
||||
actual,
|
||||
variance: actual === null ? null : cents(actual - expected),
|
||||
unaccounted: entered ? cents(expected - actual - writeOffTotal) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A closed cashup line with any later pending resolutions folded in. Pure.
|
||||
* 'received' moves pending money into the bank (actual unchanged); 'written_off' turns it
|
||||
* into a write-off (actual goes down). Lines from before settlement existed have only
|
||||
* actualAmount, so their unaccounted is the old variance.
|
||||
*/
|
||||
function applyPendingResolutions(line) {
|
||||
const resolutions = line.pendingResolutions || [];
|
||||
const received = cents(sum(resolutions.filter(r => r.outcome === 'received')));
|
||||
const lateWriteOffs = resolutions.filter(r => r.outcome === 'written_off');
|
||||
const writtenOff = cents(sum(lateWriteOffs));
|
||||
|
||||
const pending = line.pendingAmount || 0;
|
||||
const actual = line.actualAmount === null || line.actualAmount === undefined ? null : cents(line.actualAmount - writtenOff);
|
||||
const writeOffTotal = cents((line.writeOffTotal || 0) + writtenOff);
|
||||
const expected = line.expectedAmount || 0;
|
||||
|
||||
return {
|
||||
bank: line.bankAmount == null && received === 0 ? null : cents((line.bankAmount || 0) + received),
|
||||
onHand: line.onHandAmount ?? null,
|
||||
pending: line.pendingAmount ?? null,
|
||||
pendingReference: line.pendingReference || null,
|
||||
pendingReceived: received,
|
||||
pendingOutstanding: cents(Math.max(pending - received - writtenOff, 0)),
|
||||
writeOffs: [
|
||||
...(line.writeOffs || []).map(w => ({ id: w.id, category: w.category, amount: w.amount, reason: w.reason, createdAt: w.createdAt, afterClose: false })),
|
||||
...lateWriteOffs.map(r => ({ id: r.id, category: r.category || 'other', amount: r.amount, reason: r.notes, createdAt: r.createdAt, afterClose: true })),
|
||||
],
|
||||
writeOffTotal,
|
||||
actual,
|
||||
variance: actual === null ? null : cents(actual - expected),
|
||||
unaccounted: actual === null && writeOffTotal === 0 ? null : cents(expected - (actual || 0) - writeOffTotal),
|
||||
};
|
||||
}
|
||||
|
||||
// Normalizes a free-text Payment.method into one of the fixed cashup buckets.
|
||||
// Card-network wallets (Apple Pay, Google Pay, etc.) settle exactly like a card payment —
|
||||
// no separate float to reconcile — so they belong in the 'card' bucket, not 'other'.
|
||||
@@ -88,7 +192,13 @@ async function computeEventFinancials(eventId) {
|
||||
prisma.eventCashup.findMany({
|
||||
where: { eventId },
|
||||
include: {
|
||||
lines: { include: { denominations: true } },
|
||||
lines: {
|
||||
include: {
|
||||
denominations: true,
|
||||
writeOffs: { orderBy: { createdAt: 'asc' } },
|
||||
pendingResolutions: { include: { performedBy: { select: { id: true, name: true } } }, orderBy: { createdAt: 'asc' } }
|
||||
}
|
||||
},
|
||||
performedBy: { select: { id: true, name: true, email: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
@@ -169,29 +279,55 @@ async function computeEventFinancials(eventId) {
|
||||
const out = {};
|
||||
for (const m of ALL_METHODS) {
|
||||
const line = latestReconciled.lines.find(l => l.method === m);
|
||||
const settled = line ? applyPendingResolutions(line) : null;
|
||||
out[m] = {
|
||||
lineId: line ? line.id : null,
|
||||
expected: line ? line.expectedAmount : expectedCashByMethod[m],
|
||||
actual: line && line.actualAmount != null ? line.actualAmount : null,
|
||||
variance: line && line.variance != null ? line.variance : null,
|
||||
actual: settled ? settled.actual : null,
|
||||
variance: settled ? settled.variance : null,
|
||||
notes: line ? line.notes : null,
|
||||
denominations: line ? line.denominations : []
|
||||
denominations: line ? line.denominations : [],
|
||||
bank: settled ? settled.bank : null,
|
||||
onHand: settled ? settled.onHand : null,
|
||||
pending: settled ? settled.pending : null,
|
||||
pendingReference: settled ? settled.pendingReference : null,
|
||||
pendingReceived: settled ? settled.pendingReceived : 0,
|
||||
pendingOutstanding: settled ? settled.pendingOutstanding : 0,
|
||||
pendingResolutions: line ? line.pendingResolutions : [],
|
||||
writeOffs: settled ? settled.writeOffs : [],
|
||||
writeOffTotal: settled ? settled.writeOffTotal : 0,
|
||||
unaccounted: settled ? settled.unaccounted : null
|
||||
};
|
||||
}
|
||||
return out;
|
||||
})()
|
||||
} : null;
|
||||
|
||||
// Money written off at (or after) the cashup: card fees, bank charges, shortfalls.
|
||||
const writeOffsByMethod = emptyByMethod();
|
||||
const writeOffsByCategory = Object.fromEntries(WRITE_OFF_CATEGORIES.map(c => [c, 0]));
|
||||
let pendingOutstandingTotal = 0;
|
||||
for (const m of ALL_METHODS) {
|
||||
const r = reconciled?.byMethod?.[m];
|
||||
if (!r) continue;
|
||||
writeOffsByMethod[m] = r.writeOffTotal;
|
||||
pendingOutstandingTotal += r.pendingOutstanding;
|
||||
for (const w of r.writeOffs) writeOffsByCategory[w.category] = cents((writeOffsByCategory[w.category] || 0) + w.amount);
|
||||
}
|
||||
const totalWriteOffs = cents(ALL_METHODS.reduce((s, m) => s + writeOffsByMethod[m], 0));
|
||||
|
||||
// Reconciled value is truth; system (expected) value is the fallback when nothing was counted.
|
||||
// Tagged costs are added back so a reconciled cash count still yields a correct *gross* income figure —
|
||||
// costs get subtracted from profit exactly once, via totalCosts, never twice.
|
||||
// Tagged costs and write-offs are added back so income stays a *gross* figure — costs and
|
||||
// write-offs each come off profit exactly once, below, never twice. (Unaccounted money
|
||||
// isn't added back: it lowers income, like any variance.)
|
||||
const effectiveGrossIncomeByMethod = emptyByMethod();
|
||||
for (const m of ALL_METHODS) {
|
||||
const actual = reconciled?.byMethod?.[m]?.actual;
|
||||
const base = actual != null ? actual : expectedCashByMethod[m];
|
||||
const base = actual != null ? actual + writeOffsByMethod[m] : expectedCashByMethod[m];
|
||||
effectiveGrossIncomeByMethod[m] = base + costsByMethod[m];
|
||||
}
|
||||
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
|
||||
const netProfit = effectiveTotalRevenue - totalCosts;
|
||||
const netProfit = effectiveTotalRevenue - totalCosts - totalWriteOffs;
|
||||
|
||||
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown.
|
||||
// Revenue is tranche-aware: a line spanning two early-bird prices contributes each tranche
|
||||
@@ -221,6 +357,10 @@ async function computeEventFinancials(eventId) {
|
||||
reconciled,
|
||||
effectiveGrossIncomeByMethod,
|
||||
effectiveTotalRevenue,
|
||||
writeOffsByMethod,
|
||||
writeOffsByCategory,
|
||||
totalWriteOffs,
|
||||
pendingOutstandingTotal: cents(pendingOutstandingTotal),
|
||||
netProfit,
|
||||
unallocatedDonations,
|
||||
unallocatedDonationsTotal,
|
||||
@@ -362,6 +502,10 @@ module.exports = {
|
||||
METHOD_BUCKETS,
|
||||
ALL_METHODS,
|
||||
ZAR_DENOMINATIONS,
|
||||
WRITE_OFF_CATEGORIES,
|
||||
PENDING_OUTCOMES,
|
||||
settleLine,
|
||||
applyPendingResolutions,
|
||||
bucketForMethod,
|
||||
isDonationLeg,
|
||||
assertEventOpen,
|
||||
|
||||
@@ -15,9 +15,12 @@
|
||||
* 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.
|
||||
* - Bundle deals (utils/bundles.js) come off first; the coupon is worked out on what's
|
||||
* left. discountTotal = bundle discount + coupon discounts.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { applyBundles, loadBundlesFor, saveBundleApplications } = require('./bundles');
|
||||
|
||||
function normalizeCode(code) {
|
||||
return String(code || '').trim().toUpperCase();
|
||||
@@ -166,9 +169,15 @@ 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 } } },
|
||||
bundleApplications: true,
|
||||
user: { select: { id: true, role: true, email: true } },
|
||||
};
|
||||
|
||||
/** Bundle deals a basket gets before any coupon (nothing saved). */
|
||||
async function previewBundles(eventId, lines, db = prisma) {
|
||||
return applyBundles(await loadBundlesFor(eventId, {}, db), lines);
|
||||
}
|
||||
|
||||
async function findCouponByCode(code, db = prisma) {
|
||||
const normalized = normalizeCode(code);
|
||||
if (!normalized) return null;
|
||||
@@ -187,14 +196,16 @@ async function countCouponUses(couponId, userId, db = prisma) {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* would give after the basket's bundle deals (returned as `bundles`). 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 });
|
||||
const bundles = await previewBundles(eventId, lines);
|
||||
const check = validateCoupon(coupon, { eventId, user, lines: bundles.lines, deferRestrictions, ...uses });
|
||||
if (!check.ok) throw couponError(check.message);
|
||||
return { coupon, discount: computeCouponDiscount(coupon, lines) };
|
||||
return { coupon, discount: computeCouponDiscount(coupon, bundles.lines), bundles };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,7 +230,7 @@ async function applyCouponToRegistration({ registrationId, code, appliedById = n
|
||||
: 'A code has already been applied to this registration.');
|
||||
}
|
||||
|
||||
const lines = buildCouponLines(registration);
|
||||
const { lines } = await registrationBundles(registration, tx);
|
||||
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);
|
||||
@@ -228,31 +239,37 @@ async function applyCouponToRegistration({ registrationId, code, appliedById = n
|
||||
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() } });
|
||||
await recalculateRegistrationDiscount(registrationId, tx);
|
||||
await tx.registration.update({ where: { id: registrationId }, data: { updatedAt: new Date() } });
|
||||
return { coupon, discount, redemption };
|
||||
});
|
||||
}
|
||||
|
||||
/** A loaded registration's bundle deals (bundles it already has stay usable). */
|
||||
async function registrationBundles(registration, db = prisma) {
|
||||
const bundles = await loadBundlesFor(registration.eventId, { keepIds: (registration.bundleApplications || []).map(a => a.bundleId) }, db);
|
||||
return applyBundles(bundles, buildCouponLines(registration));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Re-works a registration's discounts: bundle deals first, then the coupon on what's
|
||||
* left. Percentage and comp codes are recalculated over the new items, fixed codes keep
|
||||
* their amount (capped at what's still in scope). `keepCouponAmounts` keeps every
|
||||
* coupon's amount (capped the same way): used when only prices changed, e.g. an
|
||||
* early-bird tier expiring. Updates bundleDiscount and discountTotal and returns
|
||||
* discountTotal. Pass `tx` to run inside the caller's transaction.
|
||||
*/
|
||||
async function recalculateRegistrationDiscount(registrationId, db = prisma) {
|
||||
async function recalculateRegistrationDiscount(registrationId, db = prisma, { keepCouponAmounts = false } = {}) {
|
||||
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;
|
||||
const bundles = await registrationBundles(registration, db);
|
||||
await saveBundleApplications(registrationId, registration.bundleApplications, bundles.applications, db);
|
||||
const lines = bundles.lines;
|
||||
let total = bundles.discount;
|
||||
for (const r of registration.couponRedemptions) {
|
||||
let amount;
|
||||
if (r.coupon.type === 'fixed') {
|
||||
if (r.coupon.type === 'fixed' || keepCouponAmounts) {
|
||||
const inScope = scopedLines(r.coupon, lines).reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
amount = round2(Math.min(r.discountAmount, inScope));
|
||||
} else {
|
||||
@@ -264,21 +281,22 @@ async function recalculateRegistrationDiscount(registrationId, db = prisma) {
|
||||
total += amount;
|
||||
}
|
||||
total = round2(total);
|
||||
if (Math.abs(total - registration.discountTotal) > 0.001) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total } });
|
||||
if (Math.abs(total - registration.discountTotal) > 0.001 || Math.abs(bundles.discount - registration.bundleDiscount) > 0.001) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total, bundleDiscount: bundles.discount } });
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Releases a cancelled registration's coupon uses so they can be claimed again. */
|
||||
/**
|
||||
* Releases a registration's coupon uses so they can be claimed again (cancelled, or the
|
||||
* code removed). Bundle deals stay.
|
||||
*/
|
||||
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 } });
|
||||
}
|
||||
if (released.count > 0) await recalculateRegistrationDiscount(registrationId, db);
|
||||
return released.count;
|
||||
}
|
||||
|
||||
@@ -292,6 +310,7 @@ module.exports = {
|
||||
findCouponByCode,
|
||||
countCouponUses,
|
||||
previewCoupon,
|
||||
previewBundles,
|
||||
applyCouponToRegistration,
|
||||
recalculateRegistrationDiscount,
|
||||
releaseRegistrationCoupons,
|
||||
|
||||
@@ -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,80 @@
|
||||
/**
|
||||
* Admin "view as user" — strictly read-only.
|
||||
*
|
||||
* An impersonation token is an ordinary JWT for the target user plus an `imp` claim (the
|
||||
* ImpersonationSession id), valid for at most 30 minutes. Three layers keep it read-only:
|
||||
* 1. readOnlyGuard (mounted before every route): any request carrying an impersonation
|
||||
* token that isn't GET/HEAD/OPTIONS is refused, apart from ending the session and
|
||||
* logging page views. No controller can be reached for a write.
|
||||
* 2. protect / optionalAuth check the session is live and set req.readOnly.
|
||||
* 3. GET handlers that write as a side effect (price refresh on view) skip it when
|
||||
* req.readOnly is set.
|
||||
* The only rows an impersonation session writes are its own session and request log.
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const MAX_MINUTES = 30;
|
||||
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
||||
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
|
||||
|
||||
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
|
||||
function impersonationClaim(req) {
|
||||
const h = req.headers.authorization;
|
||||
if (!h || !h.startsWith('Bearer ')) return null;
|
||||
try {
|
||||
const decoded = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
return decoded.imp ? String(decoded.imp) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this request may go ahead while impersonating. Pure. */
|
||||
function allowedWhileImpersonating(method, path) {
|
||||
if (SAFE_METHODS.includes(String(method).toUpperCase())) return true;
|
||||
const p = String(path || '').split('?')[0].replace(/\/+$/, '');
|
||||
return method.toUpperCase() === 'POST' && ALLOWED_WRITES.includes(p);
|
||||
}
|
||||
|
||||
function logRequest(sessionId, method, path, blocked = false) {
|
||||
prisma.impersonationRequestLog.create({ data: { sessionId, method, path: String(path).slice(0, 500), blocked } }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Layer 1 — mounted in index.js before every route (webhooks never carry user tokens). */
|
||||
function readOnlyGuard(req, res, next) {
|
||||
const sessionId = impersonationClaim(req);
|
||||
if (!sessionId) return next();
|
||||
const path = req.originalUrl || req.url;
|
||||
if (!allowedWhileImpersonating(req.method, path)) {
|
||||
logRequest(sessionId, req.method, path, true);
|
||||
return res.status(403).json({ message: 'Read-only while viewing as another user. End impersonation to make changes.', code: 'IMPERSONATION_READ_ONLY' });
|
||||
}
|
||||
if (SAFE_METHODS.includes(req.method) && path.startsWith('/api/')) logRequest(sessionId, req.method, path);
|
||||
next();
|
||||
}
|
||||
|
||||
/** Layer 2 — the live session for an `imp` claim, or null when ended/expired. */
|
||||
async function liveSession(sessionId) {
|
||||
const s = await prisma.impersonationSession.findUnique({ where: { id: sessionId } });
|
||||
if (!s || s.endedAt) return null;
|
||||
if (s.expiresAt <= new Date()) {
|
||||
await prisma.impersonationSession.updateMany({ where: { id: s.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'expired' } });
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Why `admin` can't view as `target`, or null. Pure. */
|
||||
function impersonationBlocker(admin, target) {
|
||||
if (!admin || admin.role !== 'admin') return 'Only admins can view as another user.';
|
||||
if (!admin.twoFactorEnabled) return 'Turn on two-factor authentication for your account first.';
|
||||
if (!target) return 'User not found.';
|
||||
if (target.id === admin.id) return "You can't view as yourself.";
|
||||
if (target.role === 'admin') return "Admins can't be impersonated.";
|
||||
if (!target.isActive) return 'That account is deactivated.';
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { MAX_MINUTES, impersonationClaim, allowedWhileImpersonating, readOnlyGuard, liveSession, impersonationBlocker, logRequest };
|
||||
@@ -2,7 +2,7 @@ const fs = require('fs');
|
||||
const prisma = require('../config/db');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, secondaryLink, divider, callout, paymentOption } = require('./email');
|
||||
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
|
||||
const { couponDiscountLine } = require('./pdfDocs');
|
||||
const { discountLines } = require('./pdfDocs');
|
||||
|
||||
// ─── Formatting helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -90,7 +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 } } } },
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: 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 } } } },
|
||||
},
|
||||
@@ -137,7 +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 } } } },
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: 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 } } } },
|
||||
},
|
||||
@@ -149,8 +149,8 @@ async function loadPaymentFull(paymentId) {
|
||||
|
||||
// ─── Shared building blocks ───────────────────────────────────────────────────
|
||||
|
||||
/** Renders the selections summary table, with the coupon discount (if any) as its own row. */
|
||||
function selectionsTable(registrationOptions, couponLine = null) {
|
||||
/** Renders the selections summary table, with each discount (bundle, coupon) as its own row. */
|
||||
function selectionsTable(registrationOptions, discounts = []) {
|
||||
const rows = (registrationOptions || []).map(ro => {
|
||||
const name = ro.eventOption?.name || 'Option';
|
||||
const qty = ro.quantity || 1;
|
||||
@@ -164,10 +164,10 @@ function selectionsTable(registrationOptions, couponLine = null) {
|
||||
|
||||
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
|
||||
|
||||
if (couponLine) {
|
||||
for (const line of discounts || []) {
|
||||
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>
|
||||
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${line.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(line.amount)}</td>
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
@@ -181,11 +181,10 @@ function selectionsTable(registrationOptions, couponLine = null) {
|
||||
</table>`;
|
||||
}
|
||||
|
||||
/** Plain-text selections, one bullet per item plus the coupon discount. */
|
||||
/** Plain-text selections, one bullet per item plus each 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)}`);
|
||||
for (const d of discountLines(reg)) lines.push(` • ${d.label} — -${fmtAmount(d.amount)}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -317,7 +316,7 @@ 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, couponDiscountLine(reg))}
|
||||
${selectionsTable(reg.registrationOptions, discountLines(reg))}
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
|
||||
@@ -358,7 +357,7 @@ 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, couponDiscountLine(reg))}
|
||||
${selectionsTable(reg.registrationOptions, discountLines(reg))}
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
|
||||
@@ -533,7 +532,7 @@ function buildPaymentReceipt(payment) {
|
||||
}
|
||||
|
||||
// Donation receipt
|
||||
const subject = `Donation received – ${eventTitle}`;
|
||||
const subject = payment.isSponsorship ? `Thank you for sponsoring a spot – ${eventTitle}` : `Donation received – ${eventTitle}`;
|
||||
const preheader = `Thank you for your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Thank you for your donation!</p>
|
||||
@@ -541,7 +540,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/>
|
||||
|
||||
@@ -95,16 +95,34 @@ function buildLineItems(registrationOptions) {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount is
|
||||
* Registration.discountTotal less the bundle part, both always present.
|
||||
*/
|
||||
function couponDiscountLine(registration) {
|
||||
const amount = Number(registration?.discountTotal || 0);
|
||||
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0)) * 100) / 100;
|
||||
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' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration's bundle-deal saving, labelled with the bundle names when
|
||||
* `bundleApplications[].bundle.name` is loaded, e.g. "Bundle: Family deal ×2".
|
||||
*/
|
||||
function bundleDiscountLine(registration) {
|
||||
const amount = Number(registration?.bundleDiscount || 0);
|
||||
if (!(amount > 0.001)) return null;
|
||||
const names = (registration.bundleApplications || [])
|
||||
.map(a => a.bundle?.name ? `${a.bundle.name}${a.count > 1 ? ` ×${a.count}` : ''}` : null)
|
||||
.filter(Boolean);
|
||||
return { amount, label: names.length ? `Bundle: ${names.join(', ')}` : 'Bundle deal' };
|
||||
}
|
||||
|
||||
/** Bundle saving then coupon discount, as display lines ({ label, amount }). */
|
||||
function discountLines(registration) {
|
||||
return [bundleDiscountLine(registration), couponDiscountLine(registration)].filter(Boolean);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -210,13 +228,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) {
|
||||
for (const line of discountLines(reg)) {
|
||||
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;
|
||||
.text(`${line.label}: -${fmtAmount(line.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
|
||||
y += 6;
|
||||
}
|
||||
if (discountLines(reg).length > 0) y += 8;
|
||||
y += 20;
|
||||
|
||||
const boxW = 160, boxH = 32;
|
||||
@@ -259,7 +277,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 extraDiscounts = discountLines(registration);
|
||||
|
||||
const filename = `invoice-${invoiceNo}.pdf`;
|
||||
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
|
||||
@@ -323,10 +341,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 || coupon) {
|
||||
if (discount > 0.01 || extraDiscounts.length > 0) {
|
||||
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
|
||||
if (discount > 0.01) totalLine(coupon ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
|
||||
if (coupon) totalLine(coupon.label.toUpperCase(), `-${fmtAmount(coupon.amount)}`, { color: '#059669' });
|
||||
if (discount > 0.01) totalLine(extraDiscounts.length ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
|
||||
for (const line of extraDiscounts) totalLine(line.label.toUpperCase(), `-${fmtAmount(line.amount)}`, { color: '#059669' });
|
||||
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
|
||||
y += 8;
|
||||
}
|
||||
@@ -369,4 +387,4 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
|
||||
return { filePath, filename };
|
||||
}
|
||||
|
||||
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, getBranding, docNumber };
|
||||
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, bundleDiscountLine, discountLines, getBranding, docNumber };
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
* - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of
|
||||
* each RegistrationOption; if its tier has expired or lost stock, resolves the next
|
||||
* applicable tier/price for just that tranche. Older tranches — already priced-in — are
|
||||
* never touched.
|
||||
* never touched. When a price changes, bundle deals are re-worked (they depend on the
|
||||
* prices charged) and coupon amounts are kept.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
@@ -269,7 +270,12 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
return false;
|
||||
}));
|
||||
|
||||
return { changed: results.some(Boolean) };
|
||||
const changed = results.some(Boolean);
|
||||
if (changed) {
|
||||
const { recalculateRegistrationDiscount } = require('./coupons');
|
||||
await recalculateRegistrationDiscount(registrationId, prisma, { keepCouponAmounts: true });
|
||||
}
|
||||
return { changed };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,6 +352,11 @@ function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
|
||||
*/
|
||||
function attachComputedTotals(registration) {
|
||||
if (!registration) return registration;
|
||||
// Never send which named attendees have an account: the person who typed their email or
|
||||
// phone mustn't be able to tell (the link is used server-side only).
|
||||
if (Array.isArray(registration.attendees)) {
|
||||
registration.attendees = registration.attendees.map(({ userId, ...a }) => a);
|
||||
}
|
||||
let lastPaymentAt = null;
|
||||
try {
|
||||
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
@@ -47,11 +47,10 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
|
||||
: `Your registration for *${eventTitle}* has been updated.`;
|
||||
|
||||
const { computeOptionLineTotal } = require('./pricing');
|
||||
const { couponDiscountLine } = require('./pdfDocs');
|
||||
const coupon = couponDiscountLine(reg);
|
||||
const { discountLines } = require('./pdfDocs');
|
||||
const items = [
|
||||
...(reg.registrationOptions || []).map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`),
|
||||
...(coupon ? [`- ${coupon.label} — -${fmtAmount(coupon.amount)}`] : []),
|
||||
...discountLines(reg).map(d => `- ${d.label} — -${fmtAmount(d.amount)}`),
|
||||
].join('\n');
|
||||
|
||||
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
|
||||
@@ -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,194 @@
|
||||
const { applyBundles, isBundleAvailable } = require('../src/utils/bundles');
|
||||
const { computeCouponDiscount } = require('../src/utils/coupons');
|
||||
|
||||
const line = (eventOptionId, quantity, unitPrice, extra = {}) => ({ eventOptionId, variantId: null, quantity, unitPrice, basePrice: unitPrice, ...extra });
|
||||
const family = { id: 'b1', name: 'Family deal', price: 500, items: [{ eventOptionId: 'adult', quantity: 5 }, { eventOptionId: 'child', quantity: 3 }] };
|
||||
const charged = (lines) => lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
|
||||
describe('applyBundles', () => {
|
||||
test('5 adults + 3 children for R500 instead of R550', () => {
|
||||
const r = applyBundles([family], [line('adult', 5, 80), line('child', 3, 50)]);
|
||||
expect(r.discount).toBe(50);
|
||||
expect(r.applications).toEqual([{ bundleId: 'b1', name: 'Family deal', count: 1, discount: 50 }]);
|
||||
expect(charged(r.lines)).toBeCloseTo(500, 6);
|
||||
expect(r.lines.every(l => l.bundled)).toBe(true);
|
||||
});
|
||||
|
||||
test('applies as many times as fits, leftovers at full price', () => {
|
||||
const r = applyBundles([family], [line('adult', 11, 80), line('child', 7, 50)]);
|
||||
expect(r.applications[0].count).toBe(2);
|
||||
expect(r.discount).toBe(100);
|
||||
// 1 adult and 1 child left over at full price.
|
||||
expect(charged(r.lines)).toBeCloseTo(1000 + 80 + 50, 6);
|
||||
expect(r.lines.filter(l => !l.bundled).map(l => [l.eventOptionId, l.quantity])).toEqual([['adult', 1], ['child', 1]]);
|
||||
});
|
||||
|
||||
test('nothing applies when the items are not all there', () => {
|
||||
const r = applyBundles([family], [line('adult', 5, 80), line('child', 2, 50)]);
|
||||
expect(r.discount).toBe(0);
|
||||
expect(r.applications).toEqual([]);
|
||||
expect(charged(r.lines)).toBe(500);
|
||||
});
|
||||
|
||||
test('best price wins: no discount when early-bird prices already beat the bundle', () => {
|
||||
const r = applyBundles([family], [line('adult', 5, 60, { basePrice: 80 }), line('child', 3, 40, { basePrice: 50 })]);
|
||||
expect(r.discount).toBe(0);
|
||||
expect(charged(r.lines)).toBe(420);
|
||||
});
|
||||
|
||||
test('the most expensive units go into the bundle first', () => {
|
||||
// Two adults bought at early-bird R60, four at R80: the bundle should take the R80 ones.
|
||||
const r = applyBundles([family], [line('adult', 2, 60), line('adult', 4, 80), line('child', 3, 50)]);
|
||||
// 4×80 + 1×60 + 3×50 = 530 → R30 saving.
|
||||
expect(r.discount).toBe(30);
|
||||
expect(r.lines.find(l => !l.bundled)).toMatchObject({ eventOptionId: 'adult', quantity: 1, unitPrice: 60 });
|
||||
});
|
||||
|
||||
test('when bundles compete, the bigger saving wins', () => {
|
||||
const pair = { id: 'b2', name: 'Pair', price: 150, items: [{ eventOptionId: 'adult', quantity: 2 }] };
|
||||
const r = applyBundles([family, pair], [line('adult', 5, 80), line('child', 3, 50)]);
|
||||
// Family saves 50 once; pairs save 10 each (two of them = 20), and would block the family deal.
|
||||
expect(r.applications.map(a => a.bundleId)).toEqual(['b1']);
|
||||
expect(r.discount).toBe(50);
|
||||
});
|
||||
|
||||
test('different bundles can stack on different items', () => {
|
||||
const pair = { id: 'b2', name: 'Pair', price: 150, items: [{ eventOptionId: 'adult', quantity: 2 }] };
|
||||
const r = applyBundles([family, pair], [line('adult', 7, 80), line('child', 3, 50)]);
|
||||
expect(r.applications).toEqual([
|
||||
{ bundleId: 'b1', name: 'Family deal', count: 1, discount: 50 },
|
||||
{ bundleId: 'b2', name: 'Pair', count: 1, discount: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a variant-specific item only takes that variant; an any-variant item takes the rest', () => {
|
||||
const shirts = { id: 'b3', name: 'Shirts', price: 300, items: [{ eventOptionId: 'shirt', variantId: 'L', quantity: 1 }, { eventOptionId: 'shirt', quantity: 2 }] };
|
||||
const lines = [
|
||||
{ eventOptionId: 'shirt', variantId: 'L', quantity: 1, unitPrice: 150, basePrice: 150 },
|
||||
{ eventOptionId: 'shirt', variantId: 'S', quantity: 2, unitPrice: 120, basePrice: 120 },
|
||||
];
|
||||
const r = applyBundles([shirts], lines);
|
||||
expect(r.discount).toBe(90);
|
||||
const noLarge = applyBundles([shirts], [{ ...lines[1], quantity: 3 }]);
|
||||
expect(noLarge.discount).toBe(0);
|
||||
});
|
||||
|
||||
test('a coupon after the bundle works out on the bundle price', () => {
|
||||
const r = applyBundles([family], [line('adult', 5, 80), line('child', 3, 50), line('lunch', 2, 40)]);
|
||||
const tenPercent = { type: 'percent', value: 10, stackEarlyBird: true, options: [] };
|
||||
// (500 + 80) × 10% = 58
|
||||
expect(computeCouponDiscount(tenPercent, r.lines)).toBe(58);
|
||||
// Best-price-wins coupons also see the bundle price as the base, so it still gives 10%.
|
||||
expect(computeCouponDiscount({ ...tenPercent, stackEarlyBird: false }, r.lines)).toBe(58);
|
||||
const childOnly = { ...tenPercent, options: [{ eventOptionId: 'child' }] };
|
||||
// The 3 children cost 150 of 550 → 136.36 of the R500 bundle → 13.64 off.
|
||||
expect(computeCouponDiscount(childOnly, r.lines)).toBe(13.64);
|
||||
});
|
||||
|
||||
test('a free bundle (R0) still works', () => {
|
||||
const r = applyBundles([{ ...family, price: 0 }], [line('adult', 5, 80), line('child', 3, 50)]);
|
||||
expect(r.discount).toBe(550);
|
||||
expect(charged(r.lines)).toBe(0);
|
||||
});
|
||||
|
||||
test('bundles with no items are ignored', () => {
|
||||
expect(applyBundles([{ id: 'x', name: 'x', price: 1, items: [] }], [line('adult', 3, 80)]).discount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pick any (mix)', () => {
|
||||
const anyFour = { id: 'm', name: 'Any 4', kind: 'mix', price: 250, setSize: 4, items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
|
||||
|
||||
test('2 adults + 2 children or 4 adults both make a set', () => {
|
||||
expect(applyBundles([anyFour], [line('adult', 2, 80), line('child', 2, 50)]).discount).toBe(10);
|
||||
expect(applyBundles([anyFour], [line('adult', 4, 80)]).discount).toBe(70);
|
||||
});
|
||||
|
||||
test('repeats, dearest tickets first, leftovers at full price', () => {
|
||||
const r = applyBundles([anyFour], [line('adult', 5, 80), line('child', 4, 50)]);
|
||||
// Set 1: 4 adults (320 → 250). Set 2: 1 adult + 3 children (230): no saving, so not applied.
|
||||
expect(r.applications[0].count).toBe(1);
|
||||
expect(r.discount).toBe(70);
|
||||
expect(charged(r.lines)).toBeCloseTo(250 + 80 + 200, 6);
|
||||
});
|
||||
|
||||
test('non-qualifying tickets are left alone', () => {
|
||||
const r = applyBundles([anyFour], [line('adult', 2, 80), line('lunch', 5, 65)]);
|
||||
expect(r.discount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buy X get Y free', () => {
|
||||
const b3g1 = { id: 'f', name: 'Buy 3 get 1', kind: 'free', price: 0, setSize: 4, freeCount: 1, items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
|
||||
|
||||
test('the cheapest ticket in the set is free', () => {
|
||||
const r = applyBundles([b3g1], [line('adult', 3, 80), line('child', 1, 50)]);
|
||||
expect(r.discount).toBe(50);
|
||||
expect(r.lines.find(l => l.unitPrice === 0)).toMatchObject({ eventOptionId: 'child', quantity: 1, bundled: true });
|
||||
});
|
||||
|
||||
test('8 tickets make two sets', () => {
|
||||
const r = applyBundles([b3g1], [line('adult', 8, 80)]);
|
||||
expect(r.applications[0].count).toBe(2);
|
||||
expect(r.discount).toBe(160);
|
||||
});
|
||||
|
||||
test('three tickets get nothing', () => {
|
||||
expect(applyBundles([b3g1], [line('adult', 3, 80)]).discount).toBe(0);
|
||||
});
|
||||
|
||||
test('a free count that is not below the set size is ignored', () => {
|
||||
expect(applyBundles([{ ...b3g1, freeCount: 4 }], [line('adult', 8, 80)]).discount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group discount', () => {
|
||||
const tenPlus = { id: 'g', name: 'Groups', kind: 'group', price: 70, setSize: 10, groupPricing: 'per_ticket', items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
|
||||
|
||||
test('10+ qualifying tickets all get the group price, once, never dearer', () => {
|
||||
const r = applyBundles([tenPlus], [line('adult', 8, 80), line('child', 4, 50)]);
|
||||
// Adults 80 → 70 (8 × 10 = 80 saved); children already cheaper, unchanged.
|
||||
expect(r.discount).toBe(80);
|
||||
expect(r.applications).toEqual([{ bundleId: 'g', name: 'Groups', count: 1, discount: 80 }]);
|
||||
expect(charged(r.lines)).toBeCloseTo(8 * 70 + 4 * 50, 6);
|
||||
});
|
||||
|
||||
test('below the minimum: nothing', () => {
|
||||
expect(applyBundles([tenPlus], [line('adult', 9, 80)]).discount).toBe(0);
|
||||
});
|
||||
|
||||
test('percentage off everything in the group', () => {
|
||||
const pct = { ...tenPlus, groupPricing: 'percent', price: 10 };
|
||||
expect(applyBundles([pct], [line('adult', 10, 80), line('child', 2, 50)]).discount).toBe(90);
|
||||
});
|
||||
|
||||
test('an exact bundle beats a group discount when it saves more', () => {
|
||||
const r = applyBundles([family, tenPlus], [line('adult', 5, 80), line('child', 5, 50)]);
|
||||
// Group: 5 × 10 = 50 saved. Family: 50 saved on 5A+3C, then group can't reach 10 again.
|
||||
// Equal savings: the first found wins, either way the total is 50.
|
||||
expect(r.discount).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeBundle', () => {
|
||||
const { describeBundle } = require('../src/utils/bundles');
|
||||
const names = [{ eventOptionId: 'a', quantity: 2, eventOption: { name: 'Adult' } }, { eventOptionId: 'c', quantity: 3, eventOption: { name: 'Child' } }];
|
||||
test('one line per kind', () => {
|
||||
expect(describeBundle({ kind: 'exact', price: 220, items: names })).toBe('2 × Adult + 3 × Child for R220');
|
||||
expect(describeBundle({ kind: 'mix', price: 250, setSize: 4, items: names })).toBe('Any 4 of Adult, Child for R250');
|
||||
expect(describeBundle({ kind: 'free', setSize: 4, freeCount: 1, items: names })).toBe('Buy 3, get 1 free: Adult, Child');
|
||||
expect(describeBundle({ kind: 'group', price: 70, setSize: 10, groupPricing: 'per_ticket', items: names })).toBe('10+ tickets (Adult, Child): R70 each');
|
||||
expect(describeBundle({ kind: 'group', price: 15, setSize: 10, groupPricing: 'percent', items: names })).toBe('10+ tickets (Adult, Child): 15% off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBundleAvailable', () => {
|
||||
const now = new Date('2026-06-01T10:00:00Z');
|
||||
test('switched off, not started, ended and empty bundles are not available', () => {
|
||||
expect(isBundleAvailable({ ...family, isActive: true }, now)).toBe(true);
|
||||
expect(isBundleAvailable({ ...family, isActive: false }, now)).toBe(false);
|
||||
expect(isBundleAvailable({ ...family, isActive: true, validFrom: '2026-06-02' }, now)).toBe(false);
|
||||
expect(isBundleAvailable({ ...family, isActive: true, validUntil: '2026-05-31' }, now)).toBe(false);
|
||||
expect(isBundleAvailable({ ...family, isActive: true, items: [] }, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
const { settleLine, applyPendingResolutions } = require('../src/utils/cashupUtils');
|
||||
|
||||
describe('settleLine', () => {
|
||||
test('card settled net of fees, with the fees written off, leaves nothing unaccounted', () => {
|
||||
const s = settleLine({
|
||||
method: 'card',
|
||||
expected: 12450,
|
||||
input: { bankAmount: '12080.40', writeOffs: [{ category: 'card_fees', amount: '369.60', reason: 'Yoco 2.95%' }] },
|
||||
});
|
||||
expect(s.actual).toBe(12080.4);
|
||||
expect(s.writeOffTotal).toBe(369.6);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
expect(s.variance).toBe(-369.6);
|
||||
expect(s.writeOffs).toEqual([{ category: 'card_fees', amount: 369.6, reason: 'Yoco 2.95%' }]);
|
||||
});
|
||||
|
||||
test('EFT with a POP not yet in the bank counts the pending money as accounted for', () => {
|
||||
const s = settleLine({ method: 'eft', expected: 8200, input: { bankAmount: 7200, pendingAmount: 1000, pendingReference: 'POP #417' } });
|
||||
expect(s.actual).toBe(8200);
|
||||
expect(s.pendingAmount).toBe(1000);
|
||||
expect(s.pendingReference).toBe('POP #417');
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('a pending reference is dropped when nothing is pending', () => {
|
||||
const s = settleLine({ method: 'eft', expected: 100, input: { bankAmount: 100, pendingReference: 'POP' } });
|
||||
expect(s.pendingReference).toBeNull();
|
||||
});
|
||||
|
||||
test('counted cash that was not deposited is on hand, and a shortfall shows as unaccounted', () => {
|
||||
const s = settleLine({ method: 'cash', expected: 3000, counted: 2950, input: { bankAmount: 2000, onHandAmount: 999 } });
|
||||
expect(s.onHandAmount).toBe(950); // derived from the count, not the typed 999
|
||||
expect(s.actual).toBe(2950);
|
||||
expect(s.unaccounted).toBe(50);
|
||||
});
|
||||
|
||||
test('a cash shortfall written off leaves nothing unaccounted', () => {
|
||||
const s = settleLine({ method: 'cash', expected: 3000, counted: 2950, input: { bankAmount: 2950, writeOffs: [{ category: 'cash_shortfall', amount: 50 }] } });
|
||||
expect(s.onHandAmount).toBe(0);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('a deposit fee comes out of the counted cash, not on top of it', () => {
|
||||
// Counted R1000; the bank credited R941.50 after a R58.50 deposit fee; nothing left on hand.
|
||||
const s = settleLine({ method: 'cash', expected: 1000, counted: 1000, input: { bankAmount: 941.5, writeOffs: [{ category: 'bank_charges', amount: 58.5 }] } });
|
||||
expect(s.onHandAmount).toBe(0);
|
||||
expect(s.actual).toBe(941.5);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('a cash shortfall write-off does not change what is on hand', () => {
|
||||
const s = settleLine({ method: 'cash', expected: 1000, counted: 980, input: { bankAmount: 480, writeOffs: [{ category: 'cash_shortfall', amount: 20 }] } });
|
||||
expect(s.onHandAmount).toBe(500);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('banking more than was counted, counting the deposit fee, is rejected', () => {
|
||||
expect(() => settleLine({ method: 'cash', expected: 1000, counted: 1000, input: { bankAmount: 1000, writeOffs: [{ category: 'bank_charges', amount: 58.5 }] } })).toThrow('More cash was deposited');
|
||||
});
|
||||
|
||||
test('cash never has a pending amount', () => {
|
||||
const s = settleLine({ method: 'cash', expected: 500, input: { onHandAmount: 500, pendingAmount: 200 } });
|
||||
expect(s.pendingAmount).toBeNull();
|
||||
expect(s.actual).toBe(500);
|
||||
});
|
||||
|
||||
test('without a count, cash on hand can be typed in', () => {
|
||||
const s = settleLine({ method: 'cash', expected: 500, counted: null, input: { bankAmount: 300, onHandAmount: 200 } });
|
||||
expect(s.actual).toBe(500);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('depositing more cash than was counted is rejected', () => {
|
||||
expect(() => settleLine({ method: 'cash', expected: 500, counted: 400, input: { bankAmount: 450 } })).toThrow('More cash was deposited');
|
||||
});
|
||||
|
||||
test('negative or non-numeric amounts are rejected', () => {
|
||||
expect(() => settleLine({ method: 'card', expected: 100, input: { bankAmount: -5 } })).toThrow('In bank');
|
||||
expect(() => settleLine({ method: 'card', expected: 100, input: { pendingAmount: 'abc' } })).toThrow('Pending');
|
||||
});
|
||||
|
||||
test('a write-off needs a known category and an amount over zero', () => {
|
||||
expect(() => settleLine({ method: 'card', expected: 100, input: { writeOffs: [{ category: 'lunch', amount: 5 }] } })).toThrow('what each write-off is for');
|
||||
expect(() => settleLine({ method: 'card', expected: 100, input: { writeOffs: [{ category: 'other', amount: 0 }] } })).toThrow('more than R0');
|
||||
});
|
||||
|
||||
test('blank write-off rows are ignored', () => {
|
||||
const s = settleLine({ method: 'card', expected: 100, input: { bankAmount: 100, writeOffs: [{ category: 'other', amount: '' }] } });
|
||||
expect(s.writeOffs).toEqual([]);
|
||||
});
|
||||
|
||||
test('nothing entered leaves the method unreconciled', () => {
|
||||
const s = settleLine({ method: 'eft', expected: 100, input: {} });
|
||||
expect(s.actual).toBeNull();
|
||||
expect(s.variance).toBeNull();
|
||||
expect(s.unaccounted).toBeNull();
|
||||
});
|
||||
|
||||
test('amounts are rounded to cents', () => {
|
||||
const s = settleLine({ method: 'card', expected: 0.3, input: { bankAmount: 0.1, pendingAmount: 0.2 } });
|
||||
expect(s.actual).toBe(0.3);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPendingResolutions', () => {
|
||||
const line = {
|
||||
expectedAmount: 8200,
|
||||
actualAmount: 8200,
|
||||
bankAmount: 7200,
|
||||
onHandAmount: null,
|
||||
pendingAmount: 1000,
|
||||
pendingReference: 'POP #417',
|
||||
writeOffTotal: 0,
|
||||
writeOffs: [],
|
||||
};
|
||||
|
||||
test('with nothing settled yet, all the pending money is outstanding', () => {
|
||||
const s = applyPendingResolutions({ ...line, pendingResolutions: [] });
|
||||
expect(s.pendingOutstanding).toBe(1000);
|
||||
expect(s.bank).toBe(7200);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('money received later moves into the bank without changing actual', () => {
|
||||
const s = applyPendingResolutions({ ...line, pendingResolutions: [{ id: 'r1', outcome: 'received', amount: 600 }] });
|
||||
expect(s.bank).toBe(7800);
|
||||
expect(s.actual).toBe(8200);
|
||||
expect(s.pendingOutstanding).toBe(400);
|
||||
expect(s.writeOffTotal).toBe(0);
|
||||
});
|
||||
|
||||
test('money written off later lowers actual and becomes a write-off', () => {
|
||||
const s = applyPendingResolutions({
|
||||
...line,
|
||||
pendingResolutions: [
|
||||
{ id: 'r1', outcome: 'received', amount: 600 },
|
||||
{ id: 'r2', outcome: 'written_off', amount: 400, category: 'other', notes: 'Never paid' },
|
||||
],
|
||||
});
|
||||
expect(s.actual).toBe(7800);
|
||||
expect(s.writeOffTotal).toBe(400);
|
||||
expect(s.pendingOutstanding).toBe(0);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
expect(s.writeOffs).toEqual([expect.objectContaining({ id: 'r2', category: 'other', amount: 400, reason: 'Never paid', afterClose: true })]);
|
||||
});
|
||||
|
||||
test('a line from before settlement existed reports its old variance as unaccounted', () => {
|
||||
const s = applyPendingResolutions({ expectedAmount: 1000, actualAmount: 980, variance: -20 });
|
||||
expect(s.bank).toBeNull();
|
||||
expect(s.pendingOutstanding).toBe(0);
|
||||
expect(s.unaccounted).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const { cleanFields, cleanAnswer, summarise } = require('../src/utils/feedback');
|
||||
|
||||
describe('cleanFields', () => {
|
||||
test('keeps labelled questions in order, tidying choices', () => {
|
||||
const out = cleanFields([
|
||||
{ type: 'rating', label: ' Overall ', isRequired: true },
|
||||
{ type: 'text', label: '' },
|
||||
{ type: 'multiple_choice', label: 'Best part', options: ['Worship', ' Talks ', 'Worship', ''] },
|
||||
{ type: 'statement', label: 'Thanks!', isRequired: true },
|
||||
]);
|
||||
expect(out.map(f => [f.type, f.label, f.order])).toEqual([['rating', 'Overall', 0], ['multiple_choice', 'Best part', 1], ['statement', 'Thanks!', 2]]);
|
||||
expect(out[1].options).toEqual(['Worship', 'Talks']);
|
||||
expect(out[2].isRequired).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects bad forms', () => {
|
||||
expect(() => cleanFields([{ type: 'stars', label: 'X' }])).toThrow(/unknown type/);
|
||||
expect(() => cleanFields([{ type: 'multiple_choice', label: 'X', options: ['only one'] }])).toThrow(/two choices/);
|
||||
expect(() => cleanFields([{ type: 'statement', label: 'Hello' }])).toThrow(/at least one question/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanAnswer', () => {
|
||||
test('ratings are whole stars from 1 to 5', () => {
|
||||
expect(cleanAnswer({ type: 'rating', label: 'R' }, '4')).toBe('4');
|
||||
expect(() => cleanAnswer({ type: 'rating', label: 'R' }, '6')).toThrow();
|
||||
expect(() => cleanAnswer({ type: 'rating', label: 'R' }, '3.5')).toThrow();
|
||||
});
|
||||
|
||||
test('multiple choice must be one of the options', () => {
|
||||
const f = { type: 'multiple_choice', label: 'M', options: ['A', 'B'] };
|
||||
expect(cleanAnswer(f, 'B')).toBe('B');
|
||||
expect(() => cleanAnswer(f, 'C')).toThrow();
|
||||
});
|
||||
|
||||
test('required questions need an answer; optional ones may be blank', () => {
|
||||
expect(() => cleanAnswer({ type: 'text', label: 'Why?', isRequired: true }, ' ')).toThrow(/Please answer/);
|
||||
expect(cleanAnswer({ type: 'text', label: 'Why?' }, '')).toBeNull();
|
||||
expect(cleanAnswer({ type: 'yes_no', label: 'Again?' }, 'YES')).toBe('yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarise', () => {
|
||||
const fields = [
|
||||
{ id: 'r', type: 'rating', label: 'Overall' },
|
||||
{ id: 'm', type: 'multiple_choice', label: 'Best part', options: ['Worship', 'Talks'] },
|
||||
{ id: 't', type: 'text', label: 'Comments' },
|
||||
{ id: 's', type: 'statement', label: 'Thanks' },
|
||||
];
|
||||
const answers = [
|
||||
{ fieldId: 'r', value: '5' }, { fieldId: 'r', value: '4' }, { fieldId: 'r', value: '4' },
|
||||
{ fieldId: 'm', value: 'Talks' }, { fieldId: 'm', value: 'Talks' }, { fieldId: 'm', value: 'Worship' },
|
||||
{ fieldId: 't', value: 'Loved it', name: 'Ann' },
|
||||
];
|
||||
|
||||
test('averages ratings with a star distribution', () => {
|
||||
const [r] = summarise(fields, answers);
|
||||
expect(r.average).toBe(4.3);
|
||||
expect(r.distribution.map(d => d.value)).toEqual([0, 0, 0, 2, 1]);
|
||||
});
|
||||
|
||||
test('counts choices and lists text answers; skips statements', () => {
|
||||
const out = summarise(fields, answers);
|
||||
expect(out.length).toBe(3);
|
||||
expect(out[1].counts).toEqual([{ label: 'Worship', value: 1 }, { label: 'Talks', value: 2 }]);
|
||||
expect(out[2].texts).toEqual([{ value: 'Loved it', name: 'Ann' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
const { allowedWhileImpersonating, impersonationBlocker } = require('../src/utils/impersonation');
|
||||
|
||||
describe('allowedWhileImpersonating', () => {
|
||||
test('reads are allowed', () => {
|
||||
expect(allowedWhileImpersonating('GET', '/api/registrations/myregistrations')).toBe(true);
|
||||
expect(allowedWhileImpersonating('HEAD', '/api/events')).toBe(true);
|
||||
expect(allowedWhileImpersonating('OPTIONS', '/api/payments')).toBe(true);
|
||||
});
|
||||
|
||||
test('every write is refused', () => {
|
||||
for (const m of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
expect(allowedWhileImpersonating(m, '/api/registrations')).toBe(false);
|
||||
expect(allowedWhileImpersonating(m, '/api/payments/yoco-checkout')).toBe(false);
|
||||
expect(allowedWhileImpersonating(m, '/api/users/profile')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('except ending the session and logging page views', () => {
|
||||
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end')).toBe(true);
|
||||
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/log?x=1')).toBe(true);
|
||||
expect(allowedWhileImpersonating('DELETE', '/api/admin/impersonate/end')).toBe(false);
|
||||
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end/../../users')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('impersonationBlocker', () => {
|
||||
const admin = { id: 'a', role: 'admin', twoFactorEnabled: true };
|
||||
const user = { id: 'u', role: 'user', isActive: true };
|
||||
|
||||
test('an admin with 2FA can view a user', () => {
|
||||
expect(impersonationBlocker(admin, user)).toBeNull();
|
||||
expect(impersonationBlocker(admin, { ...user, role: 'supervisor' })).toBeNull();
|
||||
});
|
||||
|
||||
test('refusals', () => {
|
||||
expect(impersonationBlocker({ ...admin, role: 'supervisor' }, user)).toMatch(/Only admins/);
|
||||
expect(impersonationBlocker({ ...admin, twoFactorEnabled: false }, user)).toMatch(/two-factor/);
|
||||
expect(impersonationBlocker(admin, { ...user, role: 'admin' })).toMatch(/Admins can't/);
|
||||
expect(impersonationBlocker(admin, { ...admin })).toMatch(/yourself/);
|
||||
expect(impersonationBlocker(admin, { ...user, isActive: false })).toMatch(/deactivated/);
|
||||
expect(impersonationBlocker(admin, null)).toMatch(/not found/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
const { locksClash, lockCovers, judgeSyncedScan } = require('../src/utils/scanLocks');
|
||||
|
||||
describe('locksClash', () => {
|
||||
test('a whole-event lock clashes with everything', () => {
|
||||
expect(locksClash({ sectionId: null }, 'hall')).toBe(true);
|
||||
expect(locksClash({ sectionId: 'hall' }, null)).toBe(true);
|
||||
});
|
||||
|
||||
test('section locks only clash with the same section', () => {
|
||||
expect(locksClash({ sectionId: 'hall' }, 'hall')).toBe(true);
|
||||
expect(locksClash({ sectionId: 'hall' }, 'dinner')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lockCovers', () => {
|
||||
const dinnerLock = { sectionId: 'dinner', section: { allowedOptions: [{ eventOptionId: 'meal' }] } };
|
||||
|
||||
test('a whole-event lock covers every scan', () => {
|
||||
expect(lockCovers({ sectionId: null }, { sectionId: 'x', optionId: 'y' })).toBe(true);
|
||||
});
|
||||
|
||||
test('with a section picked, only that section is covered', () => {
|
||||
expect(lockCovers(dinnerLock, { sectionId: 'dinner', optionId: 'meal' })).toBe(true);
|
||||
expect(lockCovers(dinnerLock, { sectionId: 'hall', optionId: 'meal' })).toBe(false);
|
||||
});
|
||||
|
||||
test('with no section picked, tickets the locked section admits are covered', () => {
|
||||
expect(lockCovers(dinnerLock, { optionId: 'meal' })).toBe(true);
|
||||
expect(lockCovers(dinnerLock, { optionId: 'entry' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('judgeSyncedScan', () => {
|
||||
const ok = { remaining: 2, cancelled: false, inScope: true, consentOk: true };
|
||||
|
||||
test('records a normal scan', () => {
|
||||
expect(judgeSyncedScan(ok, 1)).toEqual({ redeem: 1, conflict: null });
|
||||
});
|
||||
|
||||
test('never redeems more than is left', () => {
|
||||
expect(judgeSyncedScan(ok, 3)).toEqual({ redeem: 2, conflict: 'partly_used' });
|
||||
expect(judgeSyncedScan({ ...ok, remaining: 0 }, 1)).toEqual({ redeem: 0, conflict: 'already_used' });
|
||||
});
|
||||
|
||||
test('flags unknown, cancelled and out-of-scope tickets without recording them', () => {
|
||||
expect(judgeSyncedScan(null, 1)).toEqual({ redeem: 0, conflict: 'not_found' });
|
||||
expect(judgeSyncedScan({ ...ok, cancelled: true }, 1)).toEqual({ redeem: 0, conflict: 'cancelled' });
|
||||
expect(judgeSyncedScan({ ...ok, inScope: false }, 1)).toEqual({ redeem: 0, conflict: 'wrong_scope' });
|
||||
});
|
||||
|
||||
test('records but flags a check-in without consent', () => {
|
||||
expect(judgeSyncedScan({ ...ok, consentOk: false }, 1)).toEqual({ redeem: 1, conflict: 'consent' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
const { spotPrice, sponsorshipCharge, poolSummary } = require('../src/utils/sponsorship');
|
||||
|
||||
describe('spotPrice', () => {
|
||||
test('is the cheapest paid main ticket', () => {
|
||||
expect(spotPrice({ price: 0, eventOptions: [
|
||||
{ isMainTicket: true, price: 450 }, { isMainTicket: true, price: 300 }, { isMainTicket: false, price: 50 }, { isMainTicket: true, price: 0 },
|
||||
] })).toBe(300);
|
||||
});
|
||||
|
||||
test('falls back to the event price, or null for free events', () => {
|
||||
expect(spotPrice({ price: 200, eventOptions: [] })).toBe(200);
|
||||
expect(spotPrice({ price: 0, eventOptions: [{ isMainTicket: true, price: 0 }] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sponsorshipCharge', () => {
|
||||
test('whole spots cost the spot price each', () => {
|
||||
expect(sponsorshipCharge({ spots: 3 }, 450)).toEqual({ amount: 1350, spots: 3 });
|
||||
});
|
||||
|
||||
test('a custom amount is taken as given (2 decimals)', () => {
|
||||
expect(sponsorshipCharge({ amount: '120.555' }, 450)).toEqual({ amount: 120.56, spots: null });
|
||||
});
|
||||
|
||||
test('rejects bad input', () => {
|
||||
expect(() => sponsorshipCharge({ spots: 0 }, 450)).toThrow(/between 1/);
|
||||
expect(() => sponsorshipCharge({ spots: 1.5 }, 450)).toThrow();
|
||||
expect(() => sponsorshipCharge({ spots: 1 }, null)).toThrow(/no ticket price/);
|
||||
expect(() => sponsorshipCharge({ amount: 10 }, 450)).toThrow(/minimum/);
|
||||
expect(() => sponsorshipCharge({}, 450)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('poolSummary', () => {
|
||||
test('received, assigned and remaining, with spots funded', () => {
|
||||
const sponsorships = [{ id: 'a', amount: 900 }, { id: 'b', amount: 300 }];
|
||||
const legs = [{ originalPaymentId: 'a', amount: 450 }, { originalPaymentId: 'b', amount: -100 }];
|
||||
expect(poolSummary(sponsorships, legs, 450)).toEqual({ received: 1200, assigned: 550, remaining: 650, sponsors: 2, spotsFunded: 2 });
|
||||
});
|
||||
|
||||
test('no spot count for free events', () => {
|
||||
expect(poolSummary([{ id: 'a', amount: 50 }], [], null).spotsFunded).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
const { transferBlocker } = require('../src/utils/ticketTransfer');
|
||||
const { judgeSyncedScan } = require('../src/utils/scanLocks');
|
||||
|
||||
describe('transferBlocker', () => {
|
||||
const now = new Date('2026-10-01T10:00:00Z');
|
||||
const ticket = {
|
||||
userId: 'payer', holderUserId: 'holder', usages: [], registrationStatus: 'paid',
|
||||
event: { startDate: '2026-10-05T08:00:00Z', allowTicketTransfer: true },
|
||||
};
|
||||
const user = (id, role = 'user') => ({ id, role });
|
||||
|
||||
test('the payer and the holder can transfer', () => {
|
||||
expect(transferBlocker(ticket, user('payer'), now)).toBeNull();
|
||||
expect(transferBlocker(ticket, user('holder'), now)).toBeNull();
|
||||
});
|
||||
|
||||
test('anyone else needs to be a supervisor', () => {
|
||||
expect(transferBlocker(ticket, user('someone'), now).status).toBe(403);
|
||||
expect(transferBlocker(ticket, user('staffer', 'staff'), now).status).toBe(403);
|
||||
expect(transferBlocker(ticket, user('sup', 'supervisor'), now)).toBeNull();
|
||||
});
|
||||
|
||||
test('not once the event has started', () => {
|
||||
expect(transferBlocker({ ...ticket, event: { ...ticket.event, startDate: '2026-10-01T09:00:00Z' } }, user('sup', 'supervisor'), now).message).toMatch(/before the event starts/);
|
||||
});
|
||||
|
||||
test('not once any of it has been scanned', () => {
|
||||
expect(transferBlocker({ ...ticket, usages: [{ id: 'u1' }] }, user('payer'), now).message).toMatch(/already been used/);
|
||||
});
|
||||
|
||||
test('not for a cancelled registration', () => {
|
||||
expect(transferBlocker({ ...ticket, registrationStatus: 'cancelled' }, user('payer'), now)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('turned off per event, except for supervisors', () => {
|
||||
const off = { ...ticket, event: { ...ticket.event, allowTicketTransfer: false } };
|
||||
expect(transferBlocker(off, user('payer'), now).message).toMatch(/turned off/);
|
||||
expect(transferBlocker(off, user('admin', 'admin'), now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('offline sync of a transferred code', () => {
|
||||
test('is flagged and not recorded', () => {
|
||||
expect(judgeSyncedScan({ transferred: true }, 1)).toEqual({ redeem: 0, conflict: 'transferred' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
jest.mock('../src/config/db', () => ({
|
||||
appSetting: { findMany: jest.fn() },
|
||||
}));
|
||||
|
||||
const prisma = require('../src/config/db');
|
||||
const { encrypt } = require('../src/utils/encryption');
|
||||
const settingsCache = require('../src/utils/settingsCache');
|
||||
const {
|
||||
maskSecret, yocoMode, getYocoSecretKey, getYocoWebhookSecret, yocoAuthHeader,
|
||||
defaultWebhookUrl, webhookUrlProblem,
|
||||
} = require('../src/utils/yoco');
|
||||
|
||||
const ENV = { ...process.env };
|
||||
afterEach(() => { process.env = { ...ENV }; });
|
||||
|
||||
function dbHas(rows) {
|
||||
prisma.appSetting.findMany.mockResolvedValue(rows);
|
||||
settingsCache.invalidate();
|
||||
}
|
||||
|
||||
describe('maskSecret', () => {
|
||||
test('keeps the prefix and the last 3 characters', () => {
|
||||
expect(maskSecret('sk_test_0123456789abcdefaba')).toBe('sk_test_****************aba');
|
||||
expect(maskSecret('sk_live_0123456789xyz')).toBe('sk_live_**********xyz');
|
||||
expect(maskSecret('whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw')).toBe(`whsec_${'*'.repeat(29)}aSw`);
|
||||
});
|
||||
|
||||
test('masks the length exactly, like a card number', () => {
|
||||
const v = 'sk_test_0123456789abcdefaba';
|
||||
expect(maskSecret(v)).toHaveLength(v.length);
|
||||
});
|
||||
|
||||
test('short values show nothing after the prefix', () => {
|
||||
expect(maskSecret('sk_test_abc')).toBe('sk_test_***');
|
||||
expect(maskSecret('whsec_')).toBe('whsec_');
|
||||
});
|
||||
|
||||
test('unknown formats keep the first 4 characters', () => {
|
||||
expect(maskSecret('abcd0123456789')).toBe('abcd*******789');
|
||||
});
|
||||
|
||||
test('empty stays empty', () => {
|
||||
expect(maskSecret('')).toBe('');
|
||||
expect(maskSecret(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
test('yocoMode reads the key prefix', () => {
|
||||
expect(yocoMode('sk_test_x')).toBe('test');
|
||||
expect(yocoMode('sk_live_x')).toBe('live');
|
||||
expect(yocoMode('pk_test_x')).toBe('unknown');
|
||||
expect(yocoMode('')).toBe('unknown');
|
||||
});
|
||||
|
||||
describe('stored secrets', () => {
|
||||
test('the database wins over .env, and is decrypted', async () => {
|
||||
process.env.YOCO_SECRET_KEY = 'sk_test_fromenv';
|
||||
dbHas([{ key: 'yoco_secret_key', value: encrypt('sk_test_fromdb') }]);
|
||||
expect(await getYocoSecretKey()).toBe('sk_test_fromdb');
|
||||
});
|
||||
|
||||
test('.env is the fallback when the database has no value', async () => {
|
||||
process.env.YOCO_WEBHOOK_SECRET = 'whsec_ZW52';
|
||||
dbHas([{ key: 'yoco_webhook_secret', value: '' }]);
|
||||
expect(await getYocoWebhookSecret()).toBe('whsec_ZW52');
|
||||
});
|
||||
|
||||
test('checkouts fail clearly when no key is configured', async () => {
|
||||
delete process.env.YOCO_SECRET_KEY;
|
||||
dbHas([]);
|
||||
await expect(yocoAuthHeader()).rejects.toThrow(/not configured/);
|
||||
});
|
||||
|
||||
test('the auth header uses the stored key', async () => {
|
||||
dbHas([{ key: 'yoco_secret_key', value: encrypt('sk_live_abc') }]);
|
||||
expect(await yocoAuthHeader()).toBe('Bearer sk_live_abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook URL', () => {
|
||||
const req = { protocol: 'https', get: () => 'events-demo-api.crosscode.co.za' };
|
||||
|
||||
test('built from BACKEND_URL', () => {
|
||||
process.env.BACKEND_URL = 'https://api.example.com/';
|
||||
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/webhooks/yoco');
|
||||
});
|
||||
|
||||
test('falls back to the request origin', () => {
|
||||
delete process.env.BACKEND_URL;
|
||||
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/webhooks/yoco');
|
||||
});
|
||||
|
||||
test('must be public https', () => {
|
||||
expect(webhookUrlProblem('https://api.example.com/api/webhooks/yoco')).toBeNull();
|
||||
expect(webhookUrlProblem('http://api.example.com/api/webhooks/yoco')).toMatch(/https/);
|
||||
expect(webhookUrlProblem('https://localhost:5000/api/webhooks/yoco')).toMatch(/localhost/);
|
||||
expect(webhookUrlProblem('not a url')).toMatch(/full webhook URL/);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,6 @@ const nextConfig: NextConfig = {
|
||||
images: {
|
||||
domains: ['images.unsplash.com'],
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
Generated
+52
-65
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.31.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.31.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
@@ -31,6 +32,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"idb": "^8.0.4",
|
||||
"lucide-react": "^0.536.0",
|
||||
"next": "^15.5.24",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -342,6 +344,45 @@
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
@@ -869,9 +910,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -888,9 +926,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -907,9 +942,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -926,9 +958,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -945,9 +974,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -964,9 +990,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -983,9 +1006,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1002,9 +1022,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1021,9 +1038,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1046,9 +1060,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1071,9 +1082,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1096,9 +1104,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1121,9 +1126,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1146,9 +1148,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1171,9 +1170,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1196,9 +1192,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1455,9 +1448,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1474,9 +1464,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1493,9 +1480,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1512,9 +1496,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6865,6 +6846,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.4.tgz",
|
||||
"integrity": "sha512-jd0ITCrN+Zah4J7R/Z2jnR3W7A4BXDAjmo62MwWuSjxCiueUAf7X5rbodEdidWzeknwXHmLpdZkoiiCppNi30Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -8491,9 +8478,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
|
||||
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
|
||||
"version": "7.1.7",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.7.tgz",
|
||||
"integrity": "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.31.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
@@ -32,6 +33,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"idb": "^8.0.4",
|
||||
"lucide-react": "^0.536.0",
|
||||
"next": "^15.5.24",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -60,7 +62,8 @@
|
||||
"overrides": {
|
||||
"next": {
|
||||
"postcss": "^8.5.23"
|
||||
}
|
||||
},
|
||||
"postcss-selector-parser": "^7.1.7"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@sentry/cli@2.58.6": true,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Service worker for offline ticket scanning.
|
||||
*
|
||||
* Only the scanner page and the static files it needs are cached:
|
||||
* - /_next/static/* (hashed, never change): cache-first.
|
||||
* - The scanner page itself: network-first, falling back to the cached copy offline.
|
||||
* - The page can ask (postMessage { type: 'cache-urls', urls }) for the files it has
|
||||
* loaded to be cached up front, so it works offline straight after "Enable offline mode".
|
||||
* API calls are never cached — offline scans go through IndexedDB instead.
|
||||
*/
|
||||
const CACHE = 'hope-scanner-v1';
|
||||
const SCANNER_PATH = '/dashboard/staff/ticket-scanning';
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter(k => k.startsWith('hope-scanner-') && k !== CACHE).map(k => caches.delete(k)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
const data = event.data || {};
|
||||
if (data.type !== 'cache-urls' || !Array.isArray(data.urls)) return;
|
||||
event.waitUntil((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const sameOrigin = data.urls.filter(u => {
|
||||
try { return new URL(u, self.location.origin).origin === self.location.origin; } catch { return false; }
|
||||
});
|
||||
await Promise.all(sameOrigin.map(u => cache.add(u).catch(() => {})));
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (url.pathname.startsWith('/_next/static/')) {
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const hit = await cache.match(req);
|
||||
if (hit) return hit;
|
||||
const res = await fetch(req);
|
||||
if (res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === SCANNER_PATH) {
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
try {
|
||||
const res = await fetch(req);
|
||||
if (res.ok) cache.put(SCANNER_PATH, res.clone());
|
||||
return res;
|
||||
} catch {
|
||||
const hit = (await cache.match(req, { ignoreSearch: true })) || (await cache.match(SCANNER_PATH));
|
||||
if (hit) return hit;
|
||||
throw new Error('offline and not cached');
|
||||
}
|
||||
})());
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -16,14 +17,14 @@ export default function ForgotPasswordPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const res: any = await apiFetch("/api/users/forgot", { method: "POST", body: { email } });
|
||||
const res = await apiFetch<{ message?: string }>("/api/users/forgot", { method: "POST", body: { email } });
|
||||
setStatus(res?.message || "If that email exists, a password reset link has been sent.");
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
try {
|
||||
const parsed = JSON.parse(err?.message || "");
|
||||
const parsed = JSON.parse(errorMessage(err) || "");
|
||||
setStatus(parsed?.message || "Something went wrong");
|
||||
} catch {
|
||||
setStatus(err?.message || "Something went wrong");
|
||||
setStatus(errorMessage(err) || "Something went wrong");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -41,7 +42,7 @@ export default function ForgotPasswordPage() {
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Forgot your password?</h1>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">Enter your account email and we'll send you a link to reset your password.</p>
|
||||
<p className="text-gray-600 text-sm mb-6">Enter your account email and we'll send you a link to reset your password.</p>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
@@ -14,11 +16,11 @@ const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) {
|
||||
const { redirectUrl } = await params;
|
||||
|
||||
let event: any = null;
|
||||
let event: (Event & { message?: string }) | null = null;
|
||||
|
||||
if (VALID_ALIAS.test(redirectUrl)) {
|
||||
try {
|
||||
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
|
||||
event = await apiFetch<Event & { message?: string }>(`/api/events/by-alias/${redirectUrl}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch event:", error);
|
||||
}
|
||||
@@ -32,24 +34,24 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
|
||||
<p className="text-gray-500 mb-6">
|
||||
The page you're looking for doesn't exist.
|
||||
</p>
|
||||
<a href="/" className="text-brand-600 hover:underline transition-all duration-200">
|
||||
<Link href="/" className="text-brand-600 hover:underline transition-all duration-200">
|
||||
Back to Home →
|
||||
</a>
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.isLive || event.isActive) {
|
||||
redirect(`/events/${event.id}`);
|
||||
redirect(`/events/${event.id}?src=${encodeURIComponent(redirectUrl)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center min-h-screen text-center">
|
||||
<h1 className="text-3xl font-bold mb-2">This event has ended</h1>
|
||||
<p className="text-gray-600">Thanks for joining us! Stay tuned for the next one.</p>
|
||||
<a href="/" className="mt-4 text-brand-600 hover:underline">
|
||||
<Link href="/" className="mt-4 text-brand-600 hover:underline">
|
||||
Back to Home →
|
||||
</a>
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import React, { Suspense, useMemo, useState } from "react";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { saveToken } from "@/lib/auth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { appName } from "@/lib/siteConfig";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
function ActivateAccountContent() {
|
||||
const search = useSearchParams();
|
||||
const router = useRouter();
|
||||
const token = search.get("token") || "";
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
@@ -24,15 +25,14 @@ function ActivateAccountContent() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const data = await apiFetch<any>("/api/users/activate", { method: "POST", body: { token, password } });
|
||||
// Auto-login: save token and redirect to dashboard
|
||||
if (data?.token) {
|
||||
localStorage.setItem("token", data.token);
|
||||
}
|
||||
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password } });
|
||||
// Auto-login: save the session token (under the key AuthContext reads) and load the
|
||||
// dashboard fresh so the new session is picked up.
|
||||
if (data?.token) saveToken(data.token);
|
||||
setStatus("Your account is now active. Redirecting…");
|
||||
setTimeout(() => router.push("/dashboard"), 1500);
|
||||
} catch (err: any) {
|
||||
setStatus(err?.message || "Activation failed. The link may have expired.");
|
||||
setTimeout(() => { window.location.href = "/dashboard"; }, 1500);
|
||||
} catch (err) {
|
||||
setStatus(errorMessage(err) || "Activation failed. The link may have expired.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { History } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
const ACTIONS = [
|
||||
"refund_created",
|
||||
@@ -22,9 +24,22 @@ const ACTIONS = [
|
||||
"coupon_updated",
|
||||
"coupon_disabled",
|
||||
"coupon_applied_manual",
|
||||
"bundle_created",
|
||||
"bundle_updated",
|
||||
"bundle_deleted",
|
||||
"consent_override",
|
||||
"consent_rerequested",
|
||||
"medical_list_viewed",
|
||||
"scan_lock_acquired",
|
||||
"scan_lock_released",
|
||||
"scan_lock_force_released",
|
||||
"offline_sync",
|
||||
"ticket_transferred",
|
||||
"impersonation_started",
|
||||
"impersonation_ended",
|
||||
"yoco_config_updated",
|
||||
"yoco_webhook_registered",
|
||||
"yoco_webhook_deleted",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
@@ -41,9 +56,22 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
coupon_updated: "Coupon edited",
|
||||
coupon_disabled: "Coupon turned off",
|
||||
coupon_applied_manual: "Coupon applied/removed by staff",
|
||||
bundle_created: "Bundle deal created",
|
||||
bundle_updated: "Bundle deal edited",
|
||||
bundle_deleted: "Bundle deal deleted",
|
||||
consent_override: "Consent overridden at check-in",
|
||||
consent_rerequested: "Consent re-requested",
|
||||
medical_list_viewed: "Medical list viewed",
|
||||
scan_lock_acquired: "Offline scanning started",
|
||||
scan_lock_released: "Offline scanning ended",
|
||||
scan_lock_force_released: "Offline lock force-released",
|
||||
offline_sync: "Offline scans synced",
|
||||
ticket_transferred: "Ticket transferred",
|
||||
impersonation_started: "Impersonation started",
|
||||
impersonation_ended: "Impersonation ended",
|
||||
yoco_config_updated: "Yoco settings changed",
|
||||
yoco_webhook_registered: "Yoco webhook registered",
|
||||
yoco_webhook_deleted: "Yoco webhook deleted",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
@@ -71,6 +99,49 @@ function formatMetadata(metadata: Record<string, unknown> | null): string {
|
||||
}
|
||||
}
|
||||
|
||||
type ImpSession = {
|
||||
id: string; reason: string; startedAt: string; expiresAt: string; endedAt: string | null; endedReason: string | null;
|
||||
admin: { name: string; email: string }; target: { name: string; email: string };
|
||||
requests: { method: string; path: string; blocked: boolean; createdAt: string }[];
|
||||
};
|
||||
|
||||
/** "View as" sessions, each with the trail of pages and API calls made during it. */
|
||||
function ImpersonationSessions({ token }: { token: string }) {
|
||||
const [sessions, setSessions] = useState<ImpSession[]>([]);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
apiFetch<ImpSession[]>("/api/admin/impersonation-sessions?limit=20", { authToken: token }).then(setSessions).catch(() => setSessions([]));
|
||||
}, [token]);
|
||||
if (sessions.length === 0) return null;
|
||||
return (
|
||||
<div className="border rounded-xl bg-white shadow-sm p-4 mb-4">
|
||||
<div className="font-semibold mb-2">Impersonation sessions</div>
|
||||
<ul className="divide-y text-sm">
|
||||
{sessions.map(s => (
|
||||
<li key={s.id} className="py-2">
|
||||
<button className="w-full text-left flex flex-wrap items-baseline justify-between gap-2" onClick={() => setOpen(open === s.id ? null : s.id)}>
|
||||
<span><strong>{s.admin.name}</strong> viewed as <strong>{s.target.name}</strong> — “{s.reason}”</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(s.startedAt).toLocaleString()} · {s.endedAt ? `${s.endedReason || "ended"} ${new Date(s.endedAt).toLocaleTimeString()}` : new Date(s.expiresAt) > new Date() ? "active" : "expired"} · {s.requests.length} request(s){s.requests.some(r => r.blocked) ? ` · ${s.requests.filter(r => r.blocked).length} blocked` : ""}
|
||||
</span>
|
||||
</button>
|
||||
{open === s.id && (
|
||||
<ul className="mt-2 text-xs font-mono bg-gray-50 rounded p-2 max-h-64 overflow-auto space-y-0.5">
|
||||
{s.requests.map((r, i) => (
|
||||
<li key={i} className={r.blocked ? "text-red-700" : "text-gray-700"}>
|
||||
{new Date(r.createdAt).toLocaleTimeString()} {r.method} {r.path}{r.blocked ? " (blocked — read-only)" : ""}
|
||||
</li>
|
||||
))}
|
||||
{s.requests.length === 0 && <li className="text-gray-500">No requests recorded.</li>}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminAuditLogPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -111,14 +182,15 @@ export default function AdminAuditLogPage() {
|
||||
setEntries(res?.rows || []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setPage(p);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load audit log");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load audit log");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
}, [token, buildQuery, setError]);
|
||||
|
||||
useEffect(() => { if (token) load(1); }, [token, actionFilter, fromFilter, toFilter]);
|
||||
// load changes whenever a filter does (via buildQuery), so this refetches page 1 on filter changes.
|
||||
useEffect(() => { if (token) load(1); }, [token, load]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
@@ -127,7 +199,7 @@ export default function AdminAuditLogPage() {
|
||||
<History className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin")}>← Back to dashboard</button>
|
||||
<BackLink href="/dashboard/admin" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Admin Audit Log</h1>
|
||||
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} recorded</p>
|
||||
</div>
|
||||
@@ -138,6 +210,7 @@ export default function AdminAuditLogPage() {
|
||||
You need admin access to view the audit log.
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && token && <ImpersonationSessions token={token} />}
|
||||
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod, CashupWriteOffCategory, ReconciledByMethod, Event } from "@/types";
|
||||
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
|
||||
@@ -19,12 +21,9 @@ function denomLabel(v: number): string {
|
||||
return v >= 1 ? `R${v}` : `${Math.round(v * 100)}c`;
|
||||
}
|
||||
|
||||
type LineInput = { actualAmount: string; notes: string };
|
||||
|
||||
export default function EventCashupPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const eventId = params.id;
|
||||
const router = useRouter();
|
||||
const { token } = useAuth();
|
||||
|
||||
const [tab, setTab] = useState<"costs" | "reconciliation" | "report">("costs");
|
||||
@@ -36,25 +35,25 @@ export default function EventCashupPage() {
|
||||
|
||||
const isClosed = data?.event?.cashupStatus === "closed";
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [financials, event] = await Promise.all([
|
||||
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
|
||||
apiFetch<any>(`/api/events/${eventId}`, { authToken: token })
|
||||
apiFetch<Event>(`/api/events/${eventId}`, { authToken: token })
|
||||
]);
|
||||
setData(financials);
|
||||
setEventOptions((event.eventOptions || []).map((o: any) => ({ id: o.id, name: o.name })));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load cashup data");
|
||||
setEventOptions((event.eventOptions || []).map((o) => ({ id: o.id, name: o.name })));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load cashup data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
useEffect(() => { load(); }, [token, eventId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
@@ -64,7 +63,7 @@ export default function EventCashupPage() {
|
||||
<Wallet className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}>← Back to cashup</button>
|
||||
<BackLink href="/dashboard/admin/cashup" label="Back to cashup" />
|
||||
<h1 className="text-xl font-semibold text-gray-900 mt-0.5">{data?.event?.title || "Event"} — Cashup</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,8 +150,8 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
}
|
||||
setEditingId(null);
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to save cost");
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || "Failed to save cost");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -163,8 +162,8 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
try {
|
||||
await apiFetch(`/api/costs/${id}`, { method: "DELETE", authToken: token });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || "Failed to delete cost");
|
||||
} catch (e) {
|
||||
alert(errorMessage(e) || "Failed to delete cost");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,7 +178,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isClosed && <div className="text-xs text-gray-500">This event is closed — costs can't be changed until it's reopened.</div>}
|
||||
{isClosed && <div className="text-xs text-gray-500">This event is closed — costs can't be changed until it's reopened.</div>}
|
||||
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
@@ -257,11 +256,11 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Paid from (optional)</label>
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as any)}>
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as "" | CashupMethod)}>
|
||||
<option value="">Not from event takings</option>
|
||||
{METHODS.map(m => <option key={m} value={m}>{METHOD_LABELS[m]}</option>)}
|
||||
</select>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">If this was paid out of the door takings (e.g. cash to a vendor), tag it so the Cashup report deducts it from that method's expected amount.</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">If this was paid out of the door takings (e.g. cash to a vendor), tag it so the Cashup report deducts it from that method's expected amount.</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Notes (optional)</label>
|
||||
@@ -306,16 +305,17 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||
|
||||
const load = () => {
|
||||
const load = useCallback(() => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
|
||||
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
|
||||
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
}, [eventId, token, onCashSummaryChange]);
|
||||
|
||||
useEffect(load, [eventId, token]);
|
||||
// onCashSummaryChange is a state setter in the parent, so this only reruns when the event/token change.
|
||||
useEffect(load, [load]);
|
||||
|
||||
const rows = data?.rows || [];
|
||||
const sumOf = (m: "cash" | "card" | "eft" | "other", key: "total" | "count") =>
|
||||
@@ -461,8 +461,8 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
|
||||
method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null }
|
||||
});
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to save count");
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || "Failed to save count");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -535,40 +535,79 @@ function ReportTab({ eventId, token, data, isClosed }: {
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2 overflow-auto">
|
||||
<div className="text-sm font-medium">Revenue by method</div>
|
||||
<table className="w-full text-sm min-w-[560px]">
|
||||
<div className="text-sm font-medium">Where the money ended up</div>
|
||||
<table className="w-full text-sm min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="py-1">Method</th>
|
||||
<th className="py-1 text-right">Income</th>
|
||||
<th className="py-1 text-right">Expected cash</th>
|
||||
<th className="py-1 text-right">Actual</th>
|
||||
<th className="py-1 text-right">Variance</th>
|
||||
<th className="py-1 text-right">Expected</th>
|
||||
<th className="py-1 text-right">In bank</th>
|
||||
<th className="py-1 text-right">On hand</th>
|
||||
<th className="py-1 text-right">Pending</th>
|
||||
<th className="py-1 text-right">Written off</th>
|
||||
<th className="py-1 text-right">Unaccounted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METHODS.map(m => {
|
||||
const r = reconciled?.byMethod?.[m];
|
||||
const cell = (v: number | null | undefined) => (v != null && Math.abs(v) >= 0.005 ? money(v) : "—");
|
||||
return (
|
||||
<tr key={m} className="border-b last:border-0">
|
||||
<td className="py-1.5">{METHOD_LABELS[m]}</td>
|
||||
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">{r?.actual != null ? money(r.actual) : "—"}</td>
|
||||
<td className="py-1.5 text-right">{r?.variance != null ? money(r.variance) : "—"}</td>
|
||||
<td className="py-1.5 text-right">{cell(r?.bank)}</td>
|
||||
<td className="py-1.5 text-right">{cell(r?.onHand)}</td>
|
||||
<td className="py-1.5 text-right">{cell(r?.pendingOutstanding)}</td>
|
||||
<td className="py-1.5 text-right">{cell(r?.writeOffTotal)}</td>
|
||||
<td className={"py-1.5 text-right " + (r?.unaccounted != null && Math.abs(r.unaccounted) >= 0.005 ? "text-rose-700 font-medium" : "")}>
|
||||
{r?.unaccounted != null ? (Math.abs(r.unaccounted) >= 0.005 ? money(r.unaccounted) : "R0.00") : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{!reconciled && <div className="text-xs text-gray-400">Not settled yet. Columns fill in once the event is closed with a cashup.</div>}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const writeOffs = METHODS.flatMap(m => (reconciled?.byMethod?.[m]?.writeOffs || []).map(w => ({ ...w, method: m })));
|
||||
return writeOffs.length > 0 ? (
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2 overflow-auto">
|
||||
<div className="text-sm font-medium">Write-offs</div>
|
||||
<table className="w-full text-sm min-w-[480px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="py-1">Method</th>
|
||||
<th className="py-1">Type</th>
|
||||
<th className="py-1">Reason</th>
|
||||
<th className="py-1 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{writeOffs.map((w, i) => (
|
||||
<tr key={w.id || i} className="border-b last:border-0">
|
||||
<td className="py-1.5">{METHOD_LABELS[w.method]}</td>
|
||||
<td className="py-1.5">{WRITE_OFF_LABELS[w.category]}{w.afterClose ? " (after closing)" : ""}</td>
|
||||
<td className="py-1.5 text-gray-600">{w.reason || "—"}</td>
|
||||
<td className="py-1.5 text-right">{money(w.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{reconciled?.byMethod?.cash?.denominations?.length ? (
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2">
|
||||
<div className="text-sm font-medium">Cash denomination count</div>
|
||||
<table className="text-sm">
|
||||
<tbody>
|
||||
{reconciled.byMethod.cash.denominations.map((d: any) => (
|
||||
{reconciled.byMethod.cash.denominations.map((d) => (
|
||||
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -590,7 +629,7 @@ function ReportTab({ eventId, token, data, isClosed }: {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.costs.map((c: any) => (
|
||||
{data.costs.map((c) => (
|
||||
<tr key={c.id} className="border-b last:border-0">
|
||||
<td className="py-1.5">{c.label}</td>
|
||||
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
|
||||
@@ -602,19 +641,273 @@ function ReportTab({ eventId, token, data, isClosed }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||
<ProfitSummary data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Settlement ────────────────────────────────────────────────────────────
|
||||
// For each method, where did the expected money end up: in the bank, still on hand (cash),
|
||||
// or pending (on the card report / a POP we haven't seen in the bank yet) — and what was
|
||||
// written off (card fees, bank charges, a shortfall). Mirrors settleLine in
|
||||
// backend/src/utils/cashupUtils.js, which has the final say when the event is closed.
|
||||
|
||||
type WriteOffInput = { category: CashupWriteOffCategory; amount: string; reason: string };
|
||||
type SettlementInput = { bankAmount: string; onHandAmount: string; pendingAmount: string; pendingReference: string; writeOffs: WriteOffInput[]; notes: string };
|
||||
|
||||
const WRITE_OFF_LABELS: Record<CashupWriteOffCategory, string> = {
|
||||
card_fees: "Card / online fees",
|
||||
bank_charges: "Bank charges",
|
||||
cash_shortfall: "Cash shortfall",
|
||||
other: "Other",
|
||||
};
|
||||
const WRITE_OFF_CATEGORIES = Object.keys(WRITE_OFF_LABELS) as CashupWriteOffCategory[];
|
||||
const DEFAULT_WRITE_OFF: Record<CashupMethod, CashupWriteOffCategory> = { cash: "cash_shortfall", card: "card_fees", eft: "bank_charges", other: "other" };
|
||||
const BANK_HELP: Record<CashupMethod, string> = {
|
||||
cash: "What reached the bank, after any deposit fee",
|
||||
card: "Settled into the bank account",
|
||||
eft: "Showing on the bank statement",
|
||||
other: "Received in the bank",
|
||||
};
|
||||
const PENDING_HELP: Record<CashupMethod, string> = {
|
||||
cash: "",
|
||||
card: "On the card machine / Yoco report, not in the bank yet",
|
||||
eft: "Proof of payment received, not in the bank yet",
|
||||
other: "Expected but not in the bank yet",
|
||||
};
|
||||
const PENDING_REFERENCE_PLACEHOLDER: Record<CashupMethod, string> = { cash: "", card: "Report or batch reference", eft: "POP reference", other: "Reference" };
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const emptySettlement = (): SettlementInput => ({ bankAmount: "", onHandAmount: "", pendingAmount: "", pendingReference: "", writeOffs: [], notes: "" });
|
||||
const amountText = (v: string | number | null | undefined) => (v === null || v === undefined ? "" : String(v));
|
||||
|
||||
function settle(method: CashupMethod, expected: number, counted: number | null, input: SettlementInput) {
|
||||
const num = (v: string) => (v.trim() === "" || isNaN(Number(v)) ? null : Number(v));
|
||||
const bank = num(input.bankAmount);
|
||||
const writeOffTotal = round2(input.writeOffs.reduce((s, w) => s + (num(w.amount) || 0), 0));
|
||||
// A deposit fee comes out of the counted cash that was banked.
|
||||
const depositFees = round2(input.writeOffs.filter(w => w.category === "bank_charges").reduce((s, w) => s + (num(w.amount) || 0), 0));
|
||||
const onHand = method === "cash" ? (counted !== null ? round2(counted - (bank || 0) - depositFees) : num(input.onHandAmount)) : null;
|
||||
const pending = method === "cash" ? null : num(input.pendingAmount);
|
||||
const entered = [bank, onHand, pending].some(v => v !== null) || input.writeOffs.some(w => num(w.amount) !== null);
|
||||
const actual = entered ? round2((bank || 0) + (onHand || 0) + (pending || 0)) : null;
|
||||
return { bank, onHand, pending, writeOffTotal, actual, unaccounted: actual === null ? null : round2(expected - actual - writeOffTotal) };
|
||||
}
|
||||
|
||||
function UnaccountedBadge({ unaccounted }: { unaccounted: number | null }) {
|
||||
if (unaccounted === null) return <span className="text-xs text-gray-400">Not settled yet</span>;
|
||||
if (Math.abs(unaccounted) < 0.005) return <span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-emerald-50 text-emerald-700">All accounted for</span>;
|
||||
return (
|
||||
<span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-rose-50 text-rose-700">
|
||||
{unaccounted > 0 ? `${money(unaccounted)} unaccounted` : `${money(-unaccounted)} more than expected`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SettlementCard({ method, data, counted, input, onChange }: {
|
||||
method: CashupMethod; data: EventFinancials; counted: number | null; input: SettlementInput; onChange: (next: SettlementInput) => void;
|
||||
}) {
|
||||
const expected = data.expectedCashByMethod[method];
|
||||
const s = settle(method, expected, counted, input);
|
||||
const set = (field: keyof SettlementInput, value: string) => onChange({ ...input, [field]: value });
|
||||
const setWriteOff = (i: number, patch: Partial<WriteOffInput>) =>
|
||||
onChange({ ...input, writeOffs: input.writeOffs.map((w, j) => (j === i ? { ...w, ...patch } : w)) });
|
||||
const addWriteOff = () => onChange({ ...input, writeOffs: [...input.writeOffs, { category: DEFAULT_WRITE_OFF[method], amount: "", reason: "" }] });
|
||||
const removeWriteOff = (i: number) => onChange({ ...input, writeOffs: input.writeOffs.filter((_, j) => j !== i) });
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-3 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Donations counted as profit</div>
|
||||
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
|
||||
<div className="text-sm font-medium">{METHOD_LABELS[method]}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Expected {money(expected)}
|
||||
{data.costsByMethod[method] > 0 && <> ({money(data.paymentsByMethod[method])} taken, less {money(data.costsByMethod[method])} paid out)</>}
|
||||
</div>
|
||||
</div>
|
||||
<UnaccountedBadge unaccounted={s.unaccounted} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Total costs</div>
|
||||
<div className="font-semibold">{money(data.totalCosts)}</div>
|
||||
<label className="block text-xs text-gray-600 mb-1">In bank</label>
|
||||
<input type="number" step="0.01" min={0} className="w-full border rounded px-2 py-1.5 text-sm" value={input.bankAmount} onChange={e => set("bankAmount", e.target.value)} />
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">{BANK_HELP[method]}</p>
|
||||
</div>
|
||||
{method === "cash" ? (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Cash on hand</label>
|
||||
{counted !== null ? (
|
||||
<div className="text-sm py-1.5">{money(s.onHand)}</div>
|
||||
) : (
|
||||
<input type="number" step="0.01" min={0} className="w-full border rounded px-2 py-1.5 text-sm" value={input.onHandAmount} onChange={e => set("onHandAmount", e.target.value)} />
|
||||
)}
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{counted !== null ? `Counted ${money(counted)}, less what was deposited and any deposit fee` : "Not deposited yet. Enter staff cash counts below to work this out."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Pending</label>
|
||||
<div className="flex gap-2">
|
||||
<input type="number" step="0.01" min={0} className="w-28 border rounded px-2 py-1.5 text-sm" value={input.pendingAmount} onChange={e => set("pendingAmount", e.target.value)} />
|
||||
<input className="flex-1 min-w-0 border rounded px-2 py-1.5 text-sm" placeholder={PENDING_REFERENCE_PLACEHOLDER[method]} value={input.pendingReference} onChange={e => set("pendingReference", e.target.value)} />
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">{PENDING_HELP[method]}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-gray-600">Write-offs</div>
|
||||
{input.writeOffs.map((w, i) => (
|
||||
<div key={i} className="flex flex-wrap gap-2 items-center">
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={w.category} onChange={e => setWriteOff(i, { category: e.target.value as CashupWriteOffCategory })}>
|
||||
{WRITE_OFF_CATEGORIES.map(c => <option key={c} value={c}>{WRITE_OFF_LABELS[c]}</option>)}
|
||||
</select>
|
||||
<input type="number" step="0.01" min={0} className="w-28 border rounded px-2 py-1.5 text-sm" placeholder="Amount" value={w.amount} onChange={e => setWriteOff(i, { amount: e.target.value })} />
|
||||
<input className="flex-1 min-w-[140px] border rounded px-2 py-1.5 text-sm" placeholder="Reason (optional)" value={w.reason} onChange={e => setWriteOff(i, { reason: e.target.value })} />
|
||||
<button className="text-xs text-red-600 hover:underline" onClick={() => removeWriteOff(i)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={addWriteOff}>+ Add write-off</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Notes (optional)</label>
|
||||
<input className="w-full border rounded px-2 py-1.5 text-sm" value={input.notes} onChange={e => set("notes", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A closed method's settlement, read-only, with a way to settle pending money that has
|
||||
// since arrived (or never will) without reopening the event.
|
||||
function SettledCard({ method, r, token, onChanged }: { method: CashupMethod; r: ReconciledByMethod; token: string; onChanged: () => void }) {
|
||||
return (
|
||||
<div className="border rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Net profit</div>
|
||||
<div className="font-semibold">{money(data.netProfit)}</div>
|
||||
<div className="text-sm font-medium">{METHOD_LABELS[method]}</div>
|
||||
<div className="text-xs text-gray-500">Expected {money(r.expected)}</div>
|
||||
</div>
|
||||
<UnaccountedBadge unaccounted={r.unaccounted} />
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-sm">
|
||||
<div><dt className="text-xs text-gray-500">In bank</dt><dd>{r.bank !== null ? money(r.bank) : "—"}</dd></div>
|
||||
{method === "cash"
|
||||
? <div><dt className="text-xs text-gray-500">On hand</dt><dd>{r.onHand !== null ? money(r.onHand) : "—"}</dd></div>
|
||||
: <div><dt className="text-xs text-gray-500">Pending</dt><dd>{r.pendingOutstanding > 0 ? money(r.pendingOutstanding) : "—"}</dd></div>}
|
||||
<div><dt className="text-xs text-gray-500">Written off</dt><dd>{r.writeOffTotal > 0 ? money(r.writeOffTotal) : "—"}</dd></div>
|
||||
<div><dt className="text-xs text-gray-500">Actual</dt><dd>{r.actual !== null ? money(r.actual) : "—"}</dd></div>
|
||||
</dl>
|
||||
{r.writeOffs.length > 0 && (
|
||||
<ul className="text-xs text-gray-600 space-y-0.5">
|
||||
{r.writeOffs.map((w, i) => (
|
||||
<li key={w.id || i}>
|
||||
{WRITE_OFF_LABELS[w.category]} {money(w.amount)}{w.reason ? ` — ${w.reason}` : ""}{w.afterClose ? " (after closing)" : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{(r.pending || 0) > 0 && (
|
||||
<PendingSettler method={method} r={r} token={token} onChanged={onChanged} />
|
||||
)}
|
||||
{r.notes && <div className="text-xs text-gray-500">Notes: {r.notes}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingSettler({ method, r, token, onChanged }: { method: CashupMethod; r: ReconciledByMethod; token: string; onChanged: () => void }) {
|
||||
const [mode, setMode] = useState<"received" | "written_off" | null>(null);
|
||||
const [amount, setAmount] = useState("");
|
||||
const [category, setCategory] = useState<CashupWriteOffCategory>(DEFAULT_WRITE_OFF[method]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const start = (m: "received" | "written_off") => { setMode(m); setAmount(String(r.pendingOutstanding)); setNotes(""); setErr(null); };
|
||||
|
||||
const save = async () => {
|
||||
if (!r.lineId || !mode) return;
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/lines/${r.lineId}/pending`, { method: "POST", authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
|
||||
setMode(null);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-100 rounded p-2 space-y-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<span>
|
||||
Pending {money(r.pending)}{r.pendingReference ? ` (${r.pendingReference})` : ""}
|
||||
{r.pendingOutstanding > 0 ? <> — <strong>{money(r.pendingOutstanding)} still outstanding</strong></> : " — all settled"}
|
||||
</span>
|
||||
{r.pendingOutstanding > 0 && mode === null && (
|
||||
<span className="flex gap-3">
|
||||
<button className="text-brand-600 hover:underline" onClick={() => start("received")}>Mark received</button>
|
||||
<button className="text-red-600 hover:underline" onClick={() => start("written_off")}>Write off</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.pendingResolutions.length > 0 && (
|
||||
<ul className="text-gray-600 space-y-0.5">
|
||||
{r.pendingResolutions.map(p => (
|
||||
<li key={p.id}>
|
||||
{p.outcome === "received" ? "Received in bank" : `Written off (${WRITE_OFF_LABELS[p.category || "other"]})`} {money(p.amount)}
|
||||
{" — "}{p.performedBy?.name || "Unknown"}, {new Date(p.createdAt).toLocaleDateString()}{p.notes ? ` — ${p.notes}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{mode !== null && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<input type="number" step="0.01" min={0} className="w-28 border rounded px-2 py-1 text-sm bg-white" value={amount} onChange={e => setAmount(e.target.value)} />
|
||||
{mode === "written_off" && (
|
||||
<select className="border rounded px-2 py-1 text-sm bg-white" value={category} onChange={e => setCategory(e.target.value as CashupWriteOffCategory)}>
|
||||
{WRITE_OFF_CATEGORIES.map(c => <option key={c} value={c}>{WRITE_OFF_LABELS[c]}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<input className="flex-1 min-w-[140px] border rounded px-2 py-1 text-sm bg-white" placeholder={mode === "received" ? "Bank reference (optional)" : "Reason (optional)"} value={notes} onChange={e => setNotes(e.target.value)} />
|
||||
<button className="px-2 py-1 rounded border bg-white" onClick={() => setMode(null)} disabled={saving}>Cancel</button>
|
||||
<button className="px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : mode === "received" ? "Mark received" : "Write off"}
|
||||
</button>
|
||||
{err && <span className="text-red-600 w-full">{err}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfitSummary({ data }: { data: EventFinancials }) {
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-4 grid grid-cols-2 sm:grid-cols-5 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Donations counted as profit</div>
|
||||
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Total costs</div>
|
||||
<div className="font-semibold">{money(data.totalCosts)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Bank & card charges</div>
|
||||
<div className="font-semibold">{money(data.totalWriteOffs)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Pending (outstanding)</div>
|
||||
<div className="font-semibold">{money(data.pendingOutstandingTotal)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Net profit</div>
|
||||
<div className="font-semibold">{money(data.netProfit)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -625,70 +918,98 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void; onClosed: () => void;
|
||||
}) {
|
||||
const isClosed = data.event.cashupStatus === "closed";
|
||||
const draftLines = (data.event as any).cashupDraft?.lines as Array<{ method: string; actualAmount?: string | number; notes?: string; denominations?: { value: number; count: number }[] }> | undefined;
|
||||
const draftLines = data.event.cashupDraft?.lines;
|
||||
|
||||
const initialLines: Record<CashupMethod, LineInput> = useMemo(() => {
|
||||
const base: Record<CashupMethod, LineInput> = { cash: { actualAmount: "", notes: "" }, card: { actualAmount: "", notes: "" }, eft: { actualAmount: "", notes: "" }, other: { actualAmount: "", notes: "" } };
|
||||
const initialLines: Record<CashupMethod, SettlementInput> = useMemo(() => {
|
||||
const base: Record<CashupMethod, SettlementInput> = { cash: emptySettlement(), card: emptySettlement(), eft: emptySettlement(), other: emptySettlement() };
|
||||
for (const l of draftLines || []) {
|
||||
if (l.method in base) base[l.method as CashupMethod] = { actualAmount: l.actualAmount != null ? String(l.actualAmount) : "", notes: l.notes || "" };
|
||||
if (!(l.method in base)) continue;
|
||||
base[l.method as CashupMethod] = {
|
||||
bankAmount: amountText(l.bankAmount),
|
||||
onHandAmount: amountText(l.onHandAmount),
|
||||
pendingAmount: amountText(l.pendingAmount),
|
||||
pendingReference: l.pendingReference || "",
|
||||
writeOffs: (l.writeOffs || []).map(w => ({ category: w.category, amount: amountText(w.amount), reason: w.reason || "" })),
|
||||
notes: l.notes || "",
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}, [draftLines]);
|
||||
|
||||
const [lines, setLines] = useState<Record<CashupMethod, LineInput>>(initialLines);
|
||||
const [lines, setLines] = useState<Record<CashupMethod, SettlementInput>>(initialLines);
|
||||
const [closeNotes, setCloseNotes] = useState("");
|
||||
const [reopenNotes, setReopenNotes] = useState("");
|
||||
// Cash no longer has its own manual entry — it's the live sum of every staff member's
|
||||
// per-person count, reported up from CashByUserSection below.
|
||||
// Counted cash is the live sum of every staff member's per-person count, reported up from
|
||||
// CashByUserSection below; when there is one, cash on hand is whatever wasn't deposited.
|
||||
const [cashSummary, setCashSummary] = useState<{ actualTotal: number | null; denominations: PersonDenom[] }>({ actualTotal: null, denominations: [] });
|
||||
|
||||
useEffect(() => { setLines(initialLines); }, [initialLines]);
|
||||
|
||||
const setLine = (method: CashupMethod, field: keyof LineInput, value: string) => {
|
||||
setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } }));
|
||||
};
|
||||
const setLine = (method: CashupMethod, next: SettlementInput) => setLines(prev => ({ ...prev, [method]: next }));
|
||||
|
||||
// Cash is included with notes only — its actual/denominations are always sourced server-side
|
||||
// from per-person counts (see cashupController.closeEvent), never from this payload.
|
||||
const buildLinesPayload = () => METHODS.map(m => m === "cash"
|
||||
? { method: "cash", notes: lines.cash.notes || null }
|
||||
: { method: m, actualAmount: lines[m].actualAmount === "" ? null : parseFloat(lines[m].actualAmount), notes: lines[m].notes || null });
|
||||
// Methods with nothing expected and nothing entered are hidden to keep the screen short.
|
||||
const hasInput = (m: CashupMethod) => {
|
||||
const l = lines[m];
|
||||
return l.bankAmount !== "" || l.onHandAmount !== "" || l.pendingAmount !== "" || l.writeOffs.length > 0;
|
||||
};
|
||||
const shownMethods = METHODS.filter(m => Math.abs(data.expectedCashByMethod[m]) > 0.005 || hasInput(m) || (m === "cash" && cashSummary.actualTotal !== null));
|
||||
|
||||
const buildLinesPayload = () => METHODS.map(m => {
|
||||
const l = lines[m];
|
||||
const amount = (v: string) => (v.trim() === "" ? null : parseFloat(v));
|
||||
return {
|
||||
method: m,
|
||||
bankAmount: amount(l.bankAmount),
|
||||
onHandAmount: m === "cash" ? amount(l.onHandAmount) : null,
|
||||
pendingAmount: m === "cash" ? null : amount(l.pendingAmount),
|
||||
pendingReference: l.pendingReference || null,
|
||||
writeOffs: l.writeOffs.map(w => ({ category: w.category, amount: amount(w.amount), reason: w.reason || null })),
|
||||
notes: l.notes || null,
|
||||
};
|
||||
});
|
||||
|
||||
const saveDraft = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save draft");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save draft");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeWithCashup = async () => {
|
||||
if (!confirm("Close this event with the entered reconciliation? This will count unallocated donations as profit and fully lock the event until it's reopened.")) return;
|
||||
const open = shownMethods
|
||||
.map(m => ({ m, s: settle(m, data.expectedCashByMethod[m], m === "cash" ? cashSummary.actualTotal : null, lines[m]) }))
|
||||
.filter(({ s }) => s.unaccounted === null || Math.abs(s.unaccounted) >= 0.005)
|
||||
.map(({ m, s }) => `${METHOD_LABELS[m]}: ${s.unaccounted === null ? "not settled" : money(s.unaccounted)}`);
|
||||
const warning = open.length > 0
|
||||
? `\n\nNot everything is accounted for:\n${open.join("\n")}\n\nThis will be recorded as a variance and comes off profit.`
|
||||
: "";
|
||||
if (!confirm(`Close this event with this cashup? Unallocated donations will be counted as profit and the event will be locked until it's reopened.${warning}`)) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to close event");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to close event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const quickClose = async () => {
|
||||
if (!confirm("Quick close this event without a per-method cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return;
|
||||
if (!confirm("Quick close this event without a cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to close event");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to close event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -700,68 +1021,49 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to reopen event");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to reopen event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reconciled = data.reconciled;
|
||||
const settledMethods = METHODS.filter(m => {
|
||||
const r = reconciled?.byMethod?.[m];
|
||||
return r && (Math.abs(r.expected) > 0.005 || r.actual !== null || r.writeOffTotal > 0);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white border rounded-lg p-4 space-y-3 overflow-auto">
|
||||
<div className="text-sm font-medium">Cash-up reconciliation</div>
|
||||
<table className="w-full text-sm min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="py-1">Method</th>
|
||||
<th className="py-1 text-right">Income</th>
|
||||
<th className="py-1 text-right">Costs from method</th>
|
||||
<th className="py-1 text-right">Expected cash</th>
|
||||
<th className="py-1 text-right">Actual</th>
|
||||
<th className="py-1 text-right">Variance</th>
|
||||
<th className="py-1">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METHODS.map(m => {
|
||||
const r = reconciled?.byMethod?.[m];
|
||||
return (
|
||||
<tr key={m} className="border-b last:border-0 align-top">
|
||||
<td className="py-1.5">{METHOD_LABELS[m]}</td>
|
||||
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">{money(data.costsByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{isClosed ? (
|
||||
money(r?.actual ?? null)
|
||||
) : m === "cash" ? (
|
||||
cashSummary.actualTotal != null ? money(cashSummary.actualTotal) : <span className="text-gray-400">not counted</span>
|
||||
) : (
|
||||
<input type="number" step="0.01" className="w-28 border rounded px-2 py-1 text-sm text-right" value={lines[m].actualAmount} onChange={e => setLine(m, "actualAmount", e.target.value)} />
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{isClosed
|
||||
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
|
||||
: (m === "cash"
|
||||
? (cashSummary.actualTotal != null ? money(cashSummary.actualTotal - data.expectedCashByMethod[m]) : "—")
|
||||
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))}
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
{isClosed ? (r?.notes || "—") : (
|
||||
<input className="w-full border rounded px-2 py-1 text-sm" value={lines[m].notes} onChange={e => setLine(m, "notes", e.target.value)} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{!isClosed && (
|
||||
<div className="text-xs text-gray-500">Cash actual is the live sum of per-staff-member counts entered below — there's no separate event-wide entry.</div>
|
||||
<div className="bg-white border rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Where did the money end up?</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{isClosed
|
||||
? "As recorded when the event was closed. Pending money can be marked received or written off without reopening."
|
||||
: "For each payment method, say how much is in the bank, still on hand or pending, and write off anything lost to fees, charges or shortfalls."}
|
||||
</div>
|
||||
</div>
|
||||
{isClosed ? (
|
||||
reconciled?.action === "closed" && settledMethods.length > 0 ? (
|
||||
settledMethods.map(m => <SettledCard key={m} method={m} r={reconciled.byMethod[m]} token={token} onChanged={onChanged} />)
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">This event was quick closed, so no settlement was recorded.</div>
|
||||
)
|
||||
) : shownMethods.length === 0 ? (
|
||||
<div className="text-xs text-gray-400">No payments to settle for this event.</div>
|
||||
) : (
|
||||
shownMethods.map(m => (
|
||||
<SettlementCard
|
||||
key={m}
|
||||
method={m}
|
||||
data={data}
|
||||
counted={m === "cash" ? cashSummary.actualTotal : null}
|
||||
input={lines[m]}
|
||||
onChange={next => setLine(m, next)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -774,7 +1076,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
return denoms.length > 0 ? (
|
||||
<table className="text-sm">
|
||||
<tbody>
|
||||
{denoms.map((d: any) => (
|
||||
{denoms.map((d: PersonDenom & { id?: string }) => (
|
||||
<tr key={d.id || d.value}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -787,20 +1089,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Donations counted as profit</div>
|
||||
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Total costs</div>
|
||||
<div className="font-semibold">{money(data.totalCosts)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Net profit</div>
|
||||
<div className="font-semibold">{money(data.netProfit)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfitSummary data={data} />
|
||||
|
||||
{!isClosed && (
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2">
|
||||
|
||||
@@ -6,12 +6,15 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Wallet } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
export default function CashupLandingPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -25,15 +28,15 @@ export default function CashupLandingPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs : []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
}, [token, setError]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const now = new Date();
|
||||
@@ -53,15 +56,11 @@ export default function CashupLandingPage() {
|
||||
<Wallet className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Post-event Cashup</h1>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-xl font-semibold text-gray-900 mt-0.5">Post-event Cashup</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Set costs, reconcile takings, and close out an event. Admin only.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>Back</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -8,12 +8,13 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import type { DashboardStats, PaymentStats } from "@/types";
|
||||
|
||||
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
||||
@@ -27,9 +28,12 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
|
||||
{ href: "/dashboard/supervisor/bundles", label: "Bundle deals", description: "Tickets sold together for one price", icon: Gift },
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
|
||||
{ href: "/dashboard/admin/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
|
||||
@@ -41,6 +45,7 @@ const QUICK_ACTIONS = [
|
||||
|
||||
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
|
||||
type Overview = {
|
||||
funnel?: { views: number; paid: number; conversion: number | null } | null;
|
||||
activeEvents: number;
|
||||
registrations: OverviewMetric;
|
||||
ticketsSold: OverviewMetric;
|
||||
@@ -64,35 +69,35 @@ export default function AdminDashboardPage() {
|
||||
// /api/stats/admin covers today/week/month payment totals (unchanged from before);
|
||||
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint. Both are
|
||||
// computed server-side — no full payments/events list ever ships to the client.
|
||||
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
|
||||
const [paymentStats, setPaymentStats] = useStableState<PaymentStats | null>(null);
|
||||
const [overview, setOverview] = useStableState<Overview | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today");
|
||||
|
||||
const loadStats = async () => {
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const isFirstLoad = !hasLoadedOnce.current;
|
||||
try {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const [dash, overviewData] = await Promise.all([
|
||||
apiFetch<any>("/api/stats/admin", { authToken: token }),
|
||||
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
|
||||
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
||||
]);
|
||||
setPaymentStats(dash.paymentStats);
|
||||
setOverview(overviewData);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore errors for dashboard summaries
|
||||
} finally {
|
||||
hasLoadedOnce.current = true;
|
||||
if (isFirstLoad) setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
}, [token, setPaymentStats, setOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadStats();
|
||||
}, [token]);
|
||||
}, [token, loadStats]);
|
||||
|
||||
// Poll every 15s while the tab is visible; pause in the background and refetch
|
||||
// immediately on return instead of leaving stale numbers up.
|
||||
@@ -102,7 +107,7 @@ export default function AdminDashboardPage() {
|
||||
}, 15000, !!token);
|
||||
|
||||
const paymentsTabValue = paymentStats
|
||||
? paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth
|
||||
? (paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth) ?? 0
|
||||
: 0;
|
||||
|
||||
return (
|
||||
@@ -118,12 +123,15 @@ export default function AdminDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatCardRow>
|
||||
<StatCardRow cols={overview?.funnel ? 6 : 5}>
|
||||
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
|
||||
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
|
||||
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
|
||||
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href="/dashboard/admin/registrations" linkLabel="View registrations" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
|
||||
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
|
||||
{overview?.funnel && (
|
||||
<StatCard icon={Filter} label="Conversion (past month)" value={overview.funnel.conversion != null ? `${overview.funnel.conversion}%` : "—"} tone="violet" href={`${REPORTS_URL}?report=funnel&range=trailing_month`} linkLabel={`${overview.funnel.paid} of ${overview.funnel.views} visits`} />
|
||||
)}
|
||||
</StatCardRow>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, FormResponse, Registration } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -17,6 +20,16 @@ function fuzzyMatch(query: string, target: string): boolean {
|
||||
return tokens.every(tok => t.includes(tok));
|
||||
}
|
||||
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
|
||||
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
|
||||
const totalDueFor = (r: Registration) => r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => {
|
||||
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price || 0);
|
||||
return sum + unit * (opt.quantity || 0);
|
||||
}, 0);
|
||||
const totalPaidFor = (r: Registration) => (r.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||
|
||||
export default function AdminRegistrationsPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -28,8 +41,8 @@ export default function AdminRegistrationsPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [registrations, setRegistrations] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loadingRegs, setLoadingRegs] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
@@ -43,48 +56,48 @@ export default function AdminRegistrationsPage() {
|
||||
|
||||
// Expanded rows + form responses cache
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [formResponses, setFormResponses] = useState<Record<string, any[]>>({});
|
||||
const [formResponses, setFormResponses] = useState<Record<string, FormResponse[]>>({});
|
||||
const [loadingForms, setLoadingForms] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadRegistrations = async () => {
|
||||
const loadRegistrations = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
const list = Array.isArray(regs) ? regs.sort((a, b) => {
|
||||
const eventCompare = (a.event?.startDate || a.eventId).localeCompare(b.event?.startDate || b.eventId);
|
||||
if (eventCompare !== 0) return eventCompare;
|
||||
return (a.user?.name || a.userId).localeCompare(b.user?.name || b.userId);
|
||||
}) : [];
|
||||
setRegistrations(list);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load registrations");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load registrations");
|
||||
} finally {
|
||||
setLoadingRegs(false);
|
||||
}
|
||||
};
|
||||
}, [token, setError]);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (includePastEvents) params.set("includePast", "true");
|
||||
if (includeInactiveEvents) params.set("includeInactive", "true");
|
||||
const qs = params.toString() ? `?${params.toString()}` : "";
|
||||
const evs = await apiFetch<any[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs.sort((a: any, b: any) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
|
||||
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
|
||||
} catch {}
|
||||
};
|
||||
}, [token, includePastEvents, includeInactiveEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRegistrations();
|
||||
}, [token]);
|
||||
}, [loadRegistrations]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [token, includePastEvents, includeInactiveEvents]);
|
||||
}, [loadEvents]);
|
||||
|
||||
const toggleExpand = async (reg: any) => {
|
||||
const toggleExpand = async (reg: Registration) => {
|
||||
const id = reg.id;
|
||||
const next = new Set(expanded);
|
||||
if (next.has(id)) {
|
||||
@@ -98,8 +111,8 @@ export default function AdminRegistrationsPage() {
|
||||
if (!formResponses[id] && !loadingForms.has(id)) {
|
||||
setLoadingForms(prev => new Set(prev).add(id));
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
|
||||
const items = Array.isArray(res?.items) ? res.items : (Array.isArray(res) ? res : []);
|
||||
const res = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
|
||||
const items = Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
|
||||
setFormResponses(prev => ({ ...prev, [id]: items }));
|
||||
} catch {
|
||||
setFormResponses(prev => ({ ...prev, [id]: [] }));
|
||||
@@ -109,7 +122,7 @@ export default function AdminRegistrationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRegistration = async (reg: any) => {
|
||||
const cancelRegistration = async (reg: Registration) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return;
|
||||
@@ -117,27 +130,27 @@ export default function AdminRegistrationsPage() {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "DELETE", authToken: token });
|
||||
setInfo("Registration cancelled");
|
||||
await loadRegistrations();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to cancel registration");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to cancel registration");
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (reg: any, status: string) => {
|
||||
const updateStatus = async (reg: Registration, status: string) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "PUT", authToken: token, body: { status } });
|
||||
setInfo("Status updated");
|
||||
await loadRegistrations();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to update status");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to update status");
|
||||
}
|
||||
};
|
||||
|
||||
const eventIds = useMemo(() => new Set(events.map((ev: any) => ev.id)), [events]);
|
||||
const eventIds = useMemo(() => new Set(events.map((ev) => ev.id)), [events]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return registrations.filter((r: any) => {
|
||||
return registrations.filter((r) => {
|
||||
if (!eventIds.has(r.eventId)) return false;
|
||||
if (eventFilter && r.eventId !== eventFilter) return false;
|
||||
if (statusFilter && r.status !== statusFilter) return false;
|
||||
@@ -145,10 +158,10 @@ export default function AdminRegistrationsPage() {
|
||||
const haystack = [
|
||||
r.id, r.user?.name, r.user?.email, r.user?.phoneNumber,
|
||||
r.userId, r.event?.title, r.eventId, r.status,
|
||||
].map((x: any) => String(x || "")).join(" ");
|
||||
].map((x) => String(x || "")).join(" ");
|
||||
return fuzzyMatch(query.trim(), haystack);
|
||||
});
|
||||
}, [registrations, query, eventFilter, statusFilter]);
|
||||
}, [registrations, query, eventFilter, statusFilter, eventIds]);
|
||||
|
||||
const statusColor = (s: string) => {
|
||||
if (s === "paid") return "text-green-700 bg-green-50";
|
||||
@@ -158,15 +171,6 @@ export default function AdminRegistrationsPage() {
|
||||
return "text-gray-700 bg-gray-50";
|
||||
};
|
||||
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
|
||||
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
|
||||
const totalDueFor = (r: any) => r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => {
|
||||
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price || 0);
|
||||
return sum + unit * (opt.quantity || 0);
|
||||
}, 0);
|
||||
const totalPaidFor = (r: any) => (r.payments || []).reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
|
||||
|
||||
// Aggregate stats across the currently filtered registrations — counts by status, plus
|
||||
// revenue/outstanding totals (cancelled registrations are excluded from the money totals
|
||||
@@ -175,7 +179,7 @@ export default function AdminRegistrationsPage() {
|
||||
const counts: Record<string, number> = {};
|
||||
let totalRevenue = 0;
|
||||
let totalOutstanding = 0;
|
||||
filtered.forEach((r: any) => {
|
||||
filtered.forEach((r) => {
|
||||
counts[r.status] = (counts[r.status] || 0) + 1;
|
||||
if (r.status === "cancelled") return;
|
||||
const due = totalDueFor(r);
|
||||
@@ -194,11 +198,11 @@ export default function AdminRegistrationsPage() {
|
||||
<ClipboardList className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Manage Registrations</h1>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Manage Registrations</h1>
|
||||
<p className="text-sm text-gray-500">{registrations.length} registration{registrations.length !== 1 ? "s" : ""} total</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
@@ -275,7 +279,7 @@ export default function AdminRegistrationsPage() {
|
||||
|
||||
<div className="border rounded-xl bg-white shadow-sm">
|
||||
<ul className="divide-y text-sm">
|
||||
{filtered.map((r: any) => {
|
||||
{filtered.map((r) => {
|
||||
const totalDue = totalDueFor(r);
|
||||
const totalPaid = totalPaidFor(r);
|
||||
const outstanding = Math.max(totalDue - totalPaid, 0);
|
||||
@@ -336,7 +340,7 @@ export default function AdminRegistrationsPage() {
|
||||
<div className="mb-3">
|
||||
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{r.registrationOptions.map((opt: any) => {
|
||||
{(r.registrationOptions || []).map((opt) => {
|
||||
// A line can span multiple price tranches (e.g. tickets bought
|
||||
// before and after an early-bird tier expired) — show one row per
|
||||
// tranche so its own price/tier status is accurate, not blended.
|
||||
@@ -356,7 +360,7 @@ export default function AdminRegistrationsPage() {
|
||||
{opt.eventOption?.name || opt.eventOptionId}
|
||||
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
|
||||
</div>
|
||||
{tranches.map((t: any, idx: number) => {
|
||||
{tranches.map((t, idx) => {
|
||||
const unit = Number(t.priceSnapshot || 0);
|
||||
return (
|
||||
<div key={t.id || idx} className="text-gray-500">
|
||||
@@ -382,7 +386,7 @@ export default function AdminRegistrationsPage() {
|
||||
<div className="text-xs text-gray-400">No payments recorded.</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{r.payments.map((p: any) => (
|
||||
{(r.payments || []).map((p) => (
|
||||
<div key={p.id} className="bg-white border rounded p-2 text-xs">
|
||||
<div className="font-medium">
|
||||
{p.amount < 0 ? '-' : ''}R {Math.abs(p.amount).toFixed(2)} · {p.method || 'payment'}
|
||||
@@ -409,11 +413,11 @@ export default function AdminRegistrationsPage() {
|
||||
<div className="text-xs text-gray-400">No form responses submitted.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{responses.map((resp: any, idx: number) => (
|
||||
{responses.map((resp, idx) => (
|
||||
<div key={resp.id || idx} className="bg-white border rounded p-2">
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">Response #{idx + 1}</div>
|
||||
<div className="grid sm:grid-cols-2 gap-1.5">
|
||||
{(resp.answers || []).map((a: any) => (
|
||||
{(resp.answers || []).map((a) => (
|
||||
<div key={a.id} className="bg-gray-50 border rounded p-1.5 text-xs">
|
||||
<div className="text-[10px] text-gray-500">{a.field?.label || a.fieldId}</div>
|
||||
<div className="font-medium">{a.value}</div>
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
|
||||
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, DatabaseBackup, type LucideIcon } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, type LucideIcon } from "lucide-react";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
import { YocoTab } from "@/components/admin/YocoTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "backups";
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -22,12 +25,19 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "email", label: "Email", icon: Mail },
|
||||
{ id: "legal", label: "Legal", icon: Scale },
|
||||
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
|
||||
{ id: "yoco", label: "Yoco", icon: CreditCard },
|
||||
{ id: "backups", label: "Backups", icon: DatabaseBackup },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
/** The code from Search Console's verification meta tag; accepts the whole tag or just the code. */
|
||||
function googleVerificationCode(input: string): string {
|
||||
const m = input.match(/content\s*=\s*["']([^"']*)["']/i);
|
||||
return (m ? m[1] : input).trim();
|
||||
}
|
||||
|
||||
function Field({
|
||||
label, hint, required, children,
|
||||
}: {
|
||||
@@ -82,7 +92,6 @@ export default function SiteSettingsPage() {
|
||||
|
||||
function SiteSettingsPageInner() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { reload: reloadSettings } = useSiteSettings();
|
||||
|
||||
@@ -101,6 +110,7 @@ function SiteSettingsPageInner() {
|
||||
const [orgPhone, setOrgPhone] = useState("");
|
||||
const [orgAddress, setOrgAddress] = useState("");
|
||||
const [appBaseUrl, setAppBaseUrl] = useState("");
|
||||
const [googleVerification, setGoogleVerification] = useState("");
|
||||
|
||||
// ── Branding ──
|
||||
const [primaryColor, setPrimaryColor] = useState("#4F46E5");
|
||||
@@ -137,6 +147,7 @@ function SiteSettingsPageInner() {
|
||||
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
|
||||
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
|
||||
const [consentRetentionDays, setConsentRetentionDays] = useState("30");
|
||||
const [notifyOnImpersonation, setNotifyOnImpersonation] = useState(false);
|
||||
|
||||
// ── Load all settings once ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -149,6 +160,7 @@ function SiteSettingsPageInner() {
|
||||
setOrgPhone(s.org_phone || "");
|
||||
setOrgAddress(s.org_address || "");
|
||||
setAppBaseUrl(s.app_base_url || "");
|
||||
setGoogleVerification(s.google_site_verification || "");
|
||||
// primary_color falls back to the legacy accent_color value (which used
|
||||
// to be "the one brand color" before this 3-color system existed).
|
||||
// accent_color is only trusted as the NEW tertiary color once
|
||||
@@ -173,10 +185,11 @@ function SiteSettingsPageInner() {
|
||||
setLegalWebsiteUrl(s.legal_website_url || "");
|
||||
setLegalEffectiveDate(s.legal_effective_date || "");
|
||||
setConsentRetentionDays(s.consent_medical_retention_days || "30");
|
||||
setNotifyOnImpersonation(s.notify_user_on_impersonation === "true");
|
||||
})
|
||||
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
|
||||
.catch((e: unknown) => setResult({ ok: false, message: errorMessage(e) || "Failed to load settings" }))
|
||||
.finally(() => setLoadingInitial(false));
|
||||
}, [token]);
|
||||
}, [token, setResult]);
|
||||
|
||||
// Clear result when switching tabs
|
||||
const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); };
|
||||
@@ -190,8 +203,8 @@ function SiteSettingsPageInner() {
|
||||
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to save" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -204,6 +217,7 @@ function SiteSettingsPageInner() {
|
||||
org_phone: orgPhone.trim(),
|
||||
org_address: orgAddress.trim(),
|
||||
app_base_url: appBaseUrl.trim(),
|
||||
google_site_verification: googleVerificationCode(googleVerification),
|
||||
});
|
||||
|
||||
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
|
||||
@@ -249,8 +263,8 @@ function SiteSettingsPageInner() {
|
||||
setSuggestedColors([]);
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to save" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -274,8 +288,8 @@ function SiteSettingsPageInner() {
|
||||
setSuggestedColors([]);
|
||||
setResult({ ok: true, message: "Reset to defaults." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to reset" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to reset" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -308,8 +322,8 @@ function SiteSettingsPageInner() {
|
||||
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to save" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -322,6 +336,7 @@ function SiteSettingsPageInner() {
|
||||
legal_website_url: legalWebsiteUrl.trim(),
|
||||
legal_effective_date: legalEffectiveDate.trim(),
|
||||
consent_medical_retention_days: String(Math.max(0, parseInt(consentRetentionDays, 10) || 0)),
|
||||
notify_user_on_impersonation: notifyOnImpersonation ? "true" : "false",
|
||||
});
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
@@ -342,8 +357,9 @@ function SiteSettingsPageInner() {
|
||||
},
|
||||
});
|
||||
setSmtpTestResult({ ok: true, message: "Connection successful — check your inbox for a test email." });
|
||||
} catch (e: any) {
|
||||
setSmtpTestResult({ ok: false, message: e?.message || "SMTP test failed", raw: e?.data?.raw });
|
||||
} catch (e) {
|
||||
const raw = e instanceof ApiError && typeof e.data?.raw === "string" ? e.data.raw : undefined;
|
||||
setSmtpTestResult({ ok: false, message: errorMessage(e) || "SMTP test failed", raw });
|
||||
} finally {
|
||||
setSmtpTesting(false);
|
||||
}
|
||||
@@ -359,15 +375,10 @@ function SiteSettingsPageInner() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Site Settings</h1>
|
||||
<BackLink href="/dashboard/admin" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Site Settings</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Configure your organisation, branding, email, and legal pages.</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200"
|
||||
onClick={() => router.push("/dashboard/admin")}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
@@ -430,6 +441,10 @@ function SiteSettingsPageInner() {
|
||||
<input className={inputCls} placeholder="https://events.yourchurch.org"
|
||||
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Google Search Console verification" hint="In Search Console, add this site as a URL-prefix property and choose the HTML tag method. Paste the tag (or just its content code) here and save, then click Verify in Search Console. Leave it set to stay verified.">
|
||||
<input className={inputCls} placeholder='<meta name="google-site-verification" content="..." />'
|
||||
value={googleVerification} onChange={e => setGoogleVerification(e.target.value)} />
|
||||
</Field>
|
||||
<SaveBar saving={saving} onSave={saveOrganisation} result={result} />
|
||||
</div>
|
||||
)}
|
||||
@@ -670,6 +685,14 @@ function SiteSettingsPageInner() {
|
||||
<p className="text-xs text-gray-500 mt-1">Collected on parental consent forms. The consent record itself (who agreed, and when) is kept.</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">Support access</p>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={notifyOnImpersonation} onChange={e => setNotifyOnImpersonation(e.target.checked)} />
|
||||
<span>Email a user when an admin views their account with "Impersonate" (read-only support access), including the reason given</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SaveBar saving={saving} onSave={saveLegal} result={result} />
|
||||
</div>
|
||||
)}
|
||||
@@ -677,6 +700,9 @@ function SiteSettingsPageInner() {
|
||||
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
|
||||
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
|
||||
|
||||
{/* ── Yoco ──────────────────────────────────────────────────────────── */}
|
||||
{activeTab === "yoco" && <YocoTab active={activeTab === "yoco"} />}
|
||||
|
||||
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
</div>
|
||||
@@ -859,7 +885,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
|
||||
setStatus(res.status ?? null);
|
||||
setStatusMsg(res.message ?? null);
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
await fetchConfig();
|
||||
setStatus("FAILED");
|
||||
setStatusMsg(null);
|
||||
@@ -903,12 +929,12 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
setBusy(action);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
|
||||
const res = await apiFetch<{ message?: string }>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
|
||||
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
|
||||
await fetchStatus();
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
let msg = e?.message || `${action} failed.`;
|
||||
} catch (e) {
|
||||
let msg = errorMessage(e) || `${action} failed.`;
|
||||
try { msg = JSON.parse(msg)?.message || msg; } catch {}
|
||||
await fetchConfig();
|
||||
if (!msg.includes("SESSION_NOT_FOUND")) {
|
||||
@@ -931,8 +957,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: inputToken.trim(), instanceId: "" } });
|
||||
setInputToken("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to save token." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to save token." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
@@ -949,8 +975,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "", instanceId: inputInstanceId.trim() } });
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to save Instance ID." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
@@ -960,11 +986,11 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
setSavingInstance(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
|
||||
const res = await apiFetch<{ message?: string }>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
|
||||
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to create instance." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to create instance." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
@@ -1208,8 +1234,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
setInputToken("");
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to update." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to update." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { beginImpersonation, type ImpersonationStart } from "@/lib/impersonation";
|
||||
import { Users as UsersIcon } from "lucide-react";
|
||||
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Paginated } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
@@ -26,15 +30,6 @@ type Role = typeof roleOptions[number];
|
||||
|
||||
const notificationPreferenceOptions = ["email", "whatsapp", "both"] as const;
|
||||
|
||||
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
|
||||
function fuzzyMatch(query: string, target: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
const t = target.toLowerCase();
|
||||
if (t.includes(q)) return true;
|
||||
// token-based: all tokens must appear somewhere
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
return tokens.every(tok => t.includes(tok));
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
@@ -97,26 +92,26 @@ export default function AdminUsersPage() {
|
||||
setFetching(true);
|
||||
try {
|
||||
const url = buildQuery(p, ps);
|
||||
const res = await apiFetch<any>(url, { authToken: token });
|
||||
const res = await apiFetch<Paginated<UserItem>>(url, { authToken: token });
|
||||
setUsers(Array.isArray(res?.data) ? res.data : []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setTotalPages(res?.pages ?? 1);
|
||||
setPage(p);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load users");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load users");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
}, [token, buildQuery, pageSize, setError]);
|
||||
|
||||
// Initial load + reload on filter change
|
||||
useEffect(() => { if (token) loadUsers(1); }, [token, debouncedQuery, roleFilter, activeFilter]);
|
||||
// Initial load, and back to page 1 whenever a filter or the page size changes
|
||||
// (loadUsers is recreated when any of them do).
|
||||
useEffect(() => { if (token) loadUsers(1); }, [token, loadUsers]);
|
||||
|
||||
const goToPage = (p: number) => loadUsers(p);
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
setPageSize(newSize);
|
||||
loadUsers(1, newSize);
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
@@ -128,7 +123,7 @@ export default function AdminUsersPage() {
|
||||
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
|
||||
try {
|
||||
setCreating(true);
|
||||
const created = await apiFetch<any>("/api/users", {
|
||||
const created = await apiFetch<{ id?: string }>("/api/users", {
|
||||
method: "POST",
|
||||
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
|
||||
});
|
||||
@@ -140,8 +135,8 @@ export default function AdminUsersPage() {
|
||||
resetCreateForm();
|
||||
setCreateOpen(false);
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create user");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create user");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@@ -154,7 +149,7 @@ export default function AdminUsersPage() {
|
||||
if (!editingId) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload: any = {
|
||||
const payload: Partial<UserItem> & { password?: string } = {
|
||||
name: editData.name,
|
||||
email: editData.email,
|
||||
role: editData.role,
|
||||
@@ -171,8 +166,8 @@ export default function AdminUsersPage() {
|
||||
setEditingId(null);
|
||||
setEditData({});
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save user");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save user");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -182,8 +177,25 @@ export default function AdminUsersPage() {
|
||||
if (!confirm(`Revoke all active sessions for ${name}? They will be signed out on all devices.`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}/revoke-sessions`, { method: "POST", authToken: token! });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to revoke sessions");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to revoke sessions");
|
||||
}
|
||||
};
|
||||
|
||||
const [impTarget, setImpTarget] = useState<{ id: string; name: string } | null>(null);
|
||||
const [impReason, setImpReason] = useState("");
|
||||
const [impBusy, setImpBusy] = useState(false);
|
||||
const [impError, setImpError] = useState<string | null>(null);
|
||||
const startImpersonation = async () => {
|
||||
if (!impTarget || !token) return;
|
||||
setImpBusy(true);
|
||||
setImpError(null);
|
||||
try {
|
||||
const res = await apiFetch<ImpersonationStart>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
|
||||
beginImpersonation(res);
|
||||
} catch (e) {
|
||||
setImpError(errorMessage(e) || "Couldn't start impersonation");
|
||||
setImpBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,8 +209,8 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to reset two-factor authentication");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to reset two-factor authentication");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -207,8 +219,8 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to deactivate user");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to deactivate user");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,11 +229,29 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}/anonymize`, { method: "POST", authToken: token! });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to delete user data");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to delete user data");
|
||||
}
|
||||
};
|
||||
|
||||
const impersonateModal = impTarget && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => !impBusy && setImpTarget(null)}>
|
||||
<div className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold">View as {impTarget.name}</h3>
|
||||
<p className="text-sm text-gray-600">You'll see the site exactly as they do, for up to 30 minutes. It's strictly read-only: nothing can be registered, paid, changed or sent. The session, your reason and every page you open are recorded in the audit log.</p>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Reason (required)</label>
|
||||
<textarea className="w-full border rounded-lg px-3 py-2 text-sm" rows={2} value={impReason} onChange={e => setImpReason(e.target.value)} placeholder="e.g. Says their ticket isn't showing in My Events" maxLength={500} autoFocus />
|
||||
</div>
|
||||
{impError && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{impError}</div>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => setImpTarget(null)} disabled={impBusy}>Cancel</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50" onClick={startImpersonation} disabled={impBusy || impReason.trim().length < 5}>{impBusy ? "Starting…" : "Start viewing"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
@@ -230,12 +260,12 @@ export default function AdminUsersPage() {
|
||||
<UsersIcon className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">User Management</h1>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">User Management</h1>
|
||||
<p className="text-sm text-gray-500">{total} user{total !== 1 ? "s" : ""} total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700" onClick={() => setCreateOpen(v => !v)}>
|
||||
{createOpen ? "Close" : "Create user"}
|
||||
</button>
|
||||
@@ -367,6 +397,9 @@ export default function AdminUsersPage() {
|
||||
<td className="p-2">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
|
||||
{user?.role === "admin" && u.role !== "admin" && u.isActive && (
|
||||
<button className="px-2 py-1 text-xs rounded bg-violet-600 text-white hover:bg-violet-700" onClick={() => { setImpTarget({ id: u.id, name: u.name }); setImpReason(""); setImpError(null); }} title="See the site exactly as this user sees it (read-only, 30 minutes, audited)">Impersonate</button>
|
||||
)}
|
||||
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
|
||||
{u.twoFactorEnabled && (
|
||||
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => resetTwoFactor(u.id, u.name, u.role)} title={u.role === "admin" || u.role === "supervisor" ? "Reset two-factor authentication — they set it up again at their next login" : "Turn off two-factor authentication for a user who can no longer receive their codes"}>Reset 2FA</button>
|
||||
@@ -498,6 +531,7 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{impersonateModal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user