Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b441ffac60 | ||
|
|
6010641629 | ||
|
|
e338768f6e | ||
|
|
cc44e62379 | ||
|
|
5aba2e735f | ||
|
|
48660732fe | ||
|
|
8cb2755493 | ||
|
|
e84d390393 | ||
|
|
39558577f7 | ||
|
|
c79b74bc67 | ||
|
|
6a8ef62630 | ||
|
|
dbac248d8f | ||
|
|
b1af901d6a | ||
|
|
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 |
@@ -0,0 +1,129 @@
|
||||
# Checks every push and pull request: backend tests, frontend lint/typecheck/build, and
|
||||
# the Playwright end-to-end tests. Pushing a version tag (v1.2.3) also deploys, once the
|
||||
# DEPLOY_* secrets are set (Repository → Settings → Actions → Secrets).
|
||||
#
|
||||
# Needs a Gitea Actions runner with the "ubuntu-latest" label (Docker), e.g. act_runner:
|
||||
# https://docs.gitea.com/usage/actions/act-runner
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
# Throwaway values for the test database and tokens — never production secrets.
|
||||
JWT_SECRET: ci-only-jwt-secret-not-for-production
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci
|
||||
E2E_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci_e2e
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: events_ci
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: backend
|
||||
- name: Migrations apply cleanly
|
||||
run: npx prisma migrate deploy
|
||||
working-directory: backend
|
||||
- run: npm test
|
||||
working-directory: backend
|
||||
|
||||
frontend:
|
||||
name: Frontend lint, typecheck, build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: frontend
|
||||
- run: npx eslint src
|
||||
working-directory: frontend
|
||||
- run: npx tsc --noEmit -p .
|
||||
working-directory: frontend
|
||||
- run: npm run build
|
||||
working-directory: frontend
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:5000
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
|
||||
e2e:
|
||||
name: End-to-end tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: events_ci
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: npm ci
|
||||
- run: npm ci
|
||||
working-directory: backend
|
||||
- run: npm ci
|
||||
working-directory: frontend
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npx playwright test
|
||||
env:
|
||||
CI: "true"
|
||||
- name: Keep the report when tests fail
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
e2e/report
|
||||
e2e/test-results
|
||||
retention-days: 7
|
||||
|
||||
deploy:
|
||||
name: Deploy tagged release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend, e2e]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Deploy over SSH (skipped until DEPLOY_HOST is set)
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
if [ -z "$DEPLOY_HOST" ]; then
|
||||
echo "DEPLOY_HOST isn't set — skipping the deploy."
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p ~/.ssh
|
||||
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"cd '${DEPLOY_PATH:-~/hope-events}' && bash scripts/deploy.sh '$TAG'"
|
||||
+6
-1
@@ -39,4 +39,9 @@ hope-events-deployment-setup.txt
|
||||
|
||||
# runtime data stores (mutated by the running app, not source)
|
||||
backend/data/scheduled-emails.json
|
||||
backend/data/banner.json
|
||||
backend/data/banner.json
|
||||
# end-to-end tests
|
||||
e2e/.data/
|
||||
e2e/report/
|
||||
e2e/test-results/
|
||||
frontend/.next-e2e/
|
||||
|
||||
+233
@@ -7,6 +7,239 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.35.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Pick sessions.** A multi-day event can let people choose which sessions to come to — day passes for a conference, or a weekly drop-in class. In the event wizard choose "How people book: Pick sessions", then in Items & Pricing set a ticket to "Pick sessions (price per session)". People tick the sessions they want when they register; each session is its own line and its own ticket, at the per-session price (early-bird and staff prices apply per session; late-joiner pricing doesn't). A session can be booked until it starts (staff can book until it ends). Whole-course tickets can still be sold alongside, and stop selling when the course starts.
|
||||
- **Session capacity**: in pick-sessions events each session can have a capacity, covering picked places plus whole-course tickets. Full sessions can't be ticked. A per-session ticket's stock limit is places per session.
|
||||
- Picked-session tickets only scan on their own session ("This ticket is for Class 2 (2026-11-11)…"), on and offline. The attendance register shows one row per person with a dash for sessions they didn't book, and completion and certificates count the sessions they booked. Session reminders go only to the people booked on that session. Tickets, printouts and the ticket email show the session's date.
|
||||
- The session picker is on the public registration page, Manual registration and the self-service kiosk.
|
||||
- The roadmap to 2.0 and a handover note are now in the repo: `docs/ROADMAP-v2.md`, `docs/HANDOVER.md` (plus the previous round's spec, `docs/FEATURES-ROUND-1.md`).
|
||||
|
||||
### Changed
|
||||
|
||||
- Per-session tickets can't be changed from My Events' "edit booking" (book more sessions by registering again); they still count towards the total. At the door, per-session tickets are booked from Manual registration (the page says so).
|
||||
|
||||
## [1.34.0] - 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Background jobs page** (Admin → Site Settings → Background jobs): every scheduled job with its schedule, next run and last 10 runs (start, finish, result or error), and a "Run now" button (recorded in the audit log).
|
||||
- **End-to-end tests** with Playwright (`npm run test:e2e`): they start their own backend and frontend against a separate, freshly seeded database and cover the public pages, free and paid registration through payment, ticket, check-in and cashup, multi-day check-in with the attendance register and certificates, creating a multi-session event in the wizard, the self-service kiosk, and the jobs page.
|
||||
- **CI workflow** (`.gitea/workflows/ci.yml`): backend tests, frontend lint, typecheck and build, and the end-to-end tests on every push and pull request. It runs once a Gitea Actions runner is registered.
|
||||
- **Deploy script** (`scripts/deploy.sh <tag>`): backs up the database, checks out the tag, installs, migrates, builds and reloads PM2, going back to the previous version if a step fails before the reload. CI runs it over SSH for `v*` tags once the `DEPLOY_*` secrets are set.
|
||||
|
||||
### Changed
|
||||
|
||||
- Timed background jobs (daily summary, waitlist, feedback, session reminders and certificates, consent, analytics rollup, backup, temp cleanup) now run on a job queue in Postgres (pg-boss) instead of in-process timers. Each runs once per schedule even with more than one server process, failed runs are retried, and a slow run never overlaps the next. Schedules are cron, in `APP_TIMEZONE` (Africa/Johannesburg by default) rather than the server's clock; change one with `JOB_SCHEDULE_<NAME>`, or turn all jobs off with `JOBS_ENABLED=false`. The existing `*_ENABLED` flags still work; the old `*_JOB_INTERVAL_MS` settings are replaced by the schedules. If the queue can't start, the jobs run on timers as before.
|
||||
- The server lets a running job finish before it stops or restarts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- At the door, the Check-In tab counted every past session's check-in on a multi-day ticket, so from the second session the ticket showed as fully used. It now counts only today's session.
|
||||
|
||||
## [1.33.0] - 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-day events.** An event can run over several sessions, e.g. every Wednesday 18:30–20:30 for 5 weeks. In the event wizard choose "Multiple sessions", create the dates from a repeat pattern (daily or weekly, every N, on chosen weekdays, for a number of sessions or until a date), then edit any session's label, times or location, add one-off dates, or cancel a session (kept for history). People register and pay once and get one ticket for every session.
|
||||
- The event page shows the schedule (past sessions greyed out, the next one highlighted, cancelled ones struck through). Event cards show "5 sessions · Wed 4 Nov – Wed 2 Dec", and multi-day events stay listed until their last session, sorted by their next one. Calendar files have one entry per session. Ticket PDFs, printed tickets and the ticket email give the session range.
|
||||
- **Late joining**: a multi-day event closes registration when its first session starts, unless late joining is on (until session N, set per event). Late joiners pay full price, a pro-rata price for the sessions still to come (rounded to the nearest R10, at least R10), or a fixed late price — main tickets only, and never more than the normal price. The event and registration pages say when the late price applies.
|
||||
- **Per-session check-in**: a multi-day ticket is checked in once per session. The scanner uses today's session (staff can pick another), shows the session on the confirm screen, and says "Already checked in for Week 3 at 18:42" on a second scan. Each event chooses whether check-in is open the whole day of a session (the default) or only from 2 hours before it until it ends. Offline mode downloads the list for today's session, and synced scans count for the session at their scan time.
|
||||
- **Session reminders**: optional per event — 1 hour, 3 hours and/or 1 day before each session, by email and/or WhatsApp (each person's notification preference still applies). Off by default.
|
||||
- **Attendance register** (Dashboard → Attendance): attendees × sessions with ticks, totals per session, completion, search and filters (completed, not completed, missed the last two sessions), and Excel/PDF export. Supervisors can tick someone present or absent, recorded in the audit log. Also works for single-date events (one "Checked in" column).
|
||||
- **Completion certificates**: optional per event, in the wizard's new Attendance step. Four designs (Classic, Modern, Church, Kids) with the site logo and colour, your title and wording ({name}, {event}, {sessionsAttended}, {totalSessions}, {endDate}), an optional signature name, role and image, and who qualifies (every session, at least N sessions, or at least N%). "Preview certificate" shows a sample. Certificates are issued automatically when the event ends (and can be sent automatically), or from the attendance register: issue now, issue to one person anyway, send, download all as one PDF, or revoke. People download theirs under My Certificates on My Events. Each has a code and QR code that anyone can check on the public page `/certificates/verify/<code>`, which shows only the name, event and dates.
|
||||
- Saving an event where sessions moved or were cancelled offers a "Message attendees" link, since registrants aren't told automatically.
|
||||
- A background job every 5 minutes sends due session reminders and issues certificates (`SESSION_JOBS_ENABLED=false` to turn it off). The church's timezone for "today" defaults to Africa/Johannesburg (`APP_TIMEZONE` to change).
|
||||
|
||||
### Changed
|
||||
|
||||
- The event wizard has a ninth step, Attendance, for late joining, check-in window, session reminders and certificates.
|
||||
|
||||
## [1.32.0] - 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
- Event cards on the home page and events list show a diagonal "Early bird" ribbon in the top-right corner while the event has an early-bird tier running (deadline not passed and places left on that tier). It isn't shown when the event is sold out, registration is closed or not open yet, or the event is contact-only.
|
||||
|
||||
## [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
|
||||
|
||||
+74
-15
@@ -56,7 +56,9 @@ Four roles, each including everything the roles below it can do:
|
||||
and in-progress events, and a "Join Us" button (becomes "My Dashboard" when
|
||||
logged in).
|
||||
- **Events list** (`/events`) — event cards with picture, dates, location,
|
||||
price, and a Register / Sold Out / Contact us button.
|
||||
price, and a Register / Sold Out / Contact us button. A diagonal "Early bird"
|
||||
ribbon in the top-right corner while an early-bird tier is running and
|
||||
registration is open.
|
||||
- **Event detail** (`/events/[id]`) — description, ticket options with
|
||||
variant and early-bird pricing, low-stock and sold-out badges, downloadable
|
||||
attachments, embedded Google Map with "Directions" link, and an
|
||||
@@ -77,14 +79,41 @@ Four roles, each including everything the roles below it can do:
|
||||
|
||||
## Events
|
||||
|
||||
Created and edited by supervisors through a 6-step wizard: **Basic Details →
|
||||
Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
Created and edited by supervisors through a 9-step wizard: **Basic Details →
|
||||
Items & Pricing → Sections → Form → Visibility → Notifications → Attachments →
|
||||
Feedback → Attendance**.
|
||||
|
||||
- **Basic details** — title, description, start/end dates, registration
|
||||
deadline, picture (upload or URL), location (defaults to the org's address),
|
||||
contact person.
|
||||
- **Contact-only events** — for things like baptisms: shown publicly with a
|
||||
"Contact us" popup instead of registration, with no tickets.
|
||||
- **Multi-day events (sessions)** — one event on several dates, e.g. every
|
||||
Wednesday 18:30–20:30 for 5 weeks. A repeat pattern (daily/weekly, every N,
|
||||
chosen weekdays, a number of sessions or an end date) creates the sessions,
|
||||
which can then be edited one by one (label, times, location), added to, or
|
||||
cancelled (kept for history; one with check-ins can't be deleted). People
|
||||
register and pay once and get one ticket. The event page shows the schedule
|
||||
with the next session highlighted; cards show "5 sessions · Wed 4 Nov – Wed
|
||||
2 Dec" and stay listed until the last session. Calendar files get one entry
|
||||
per session. Saving moved or cancelled sessions offers to message attendees.
|
||||
- **Pick sessions** (multi-day) — instead of the whole course, people can choose
|
||||
which sessions to come to (day passes, drop-in classes). Tickets set to "Pick
|
||||
sessions" are priced and bought per session — one line and one ticket per session,
|
||||
bookable until the session starts (staff: until it ends) — alongside optional
|
||||
whole-course tickets. Sessions can have a capacity (picked places plus whole-course
|
||||
tickets); a per-session ticket's stock limit is places per session. Picked tickets
|
||||
only scan on their own session; reminders, the register and certificates follow
|
||||
the sessions each person booked. Not available on youth events.
|
||||
- **Late joining** (multi-day) — registration closes when the first session
|
||||
starts, or later (until session N) with late joining on. Late joiners pay
|
||||
full price, a pro-rata price for the sessions left (rounded to the nearest
|
||||
R10), or a fixed late price — main tickets only.
|
||||
- **Completion certificates** — optional per event: four designs (Classic,
|
||||
Modern, Church, Kids), custom wording with placeholders, signature name/role/
|
||||
image, and a completion rule (every session, at least N sessions, or N%).
|
||||
Issued automatically when the event ends; each has a code and QR code checked
|
||||
on a public verify page.
|
||||
- **Ticket options** — multiple options per event (e.g. Adult, Child, Dinner,
|
||||
T-shirt), each with a price and optional **stock limit**. One option is marked
|
||||
as the main ticket, which is what gets checked in at the door.
|
||||
@@ -164,7 +193,8 @@ 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.
|
||||
@@ -239,6 +269,15 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
and remaining quantity.
|
||||
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were
|
||||
checked in and how many remain.
|
||||
- **Per-session check-in** — multi-day tickets are checked in once per session.
|
||||
The scanner uses today's session (or one picked from a list). Check-in is open
|
||||
the whole day of a session, or only from 2 hours before it until it ends, per
|
||||
event. Optional reminders (1 h / 3 h / 1 day before each session) by email
|
||||
and/or WhatsApp.
|
||||
- **Attendance register** — attendees × sessions with ticks, totals per session,
|
||||
completion, filters (completed, missed the last two), Excel/PDF export.
|
||||
Supervisors can tick someone present or absent (audit-logged), and issue,
|
||||
send, download or revoke certificates.
|
||||
- **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
|
||||
@@ -343,7 +382,7 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
|
||||
tickets sold, each vs. the previous month), revenue trend chart, top events,
|
||||
and recent scans. Tiles link to the matching report.
|
||||
- **Manage events** — the 6-step event wizard, ticket options, variants,
|
||||
- **Manage events** — the 9-step event wizard, ticket options, variants,
|
||||
sections, form and attachments.
|
||||
- **At the door** — search by name/phone/ticket ID, walk-in registration,
|
||||
payments, a **Check-In** tab for redeeming main tickets by quantity, and
|
||||
@@ -456,6 +495,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.
|
||||
@@ -497,16 +537,25 @@ falling back to a general guide.
|
||||
|
||||
## Background jobs
|
||||
|
||||
Timed jobs run on a job queue in Postgres (pg-boss, `backend/src/jobs/index.js`): each
|
||||
runs once per schedule even with several server processes, every run is recorded,
|
||||
failed runs are retried twice, and a slow run never overlaps the next. Admin → Site
|
||||
Settings → Background jobs shows each job's schedule, next run and last 10 runs, with
|
||||
"Run now". Times are in `APP_TIMEZONE` (default Africa/Johannesburg). Change a schedule
|
||||
with `JOB_SCHEDULE_<NAME>` (cron); `JOBS_ENABLED=false` turns them all off. If the queue
|
||||
can't start, the same jobs run on in-process timers.
|
||||
|
||||
| Job | Schedule |
|
||||
|-----|----------|
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
|
||||
| Daily event summary email | 07:00 daily |
|
||||
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
|
||||
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) |
|
||||
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) |
|
||||
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
|
||||
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
|
||||
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h (not on the queue) |
|
||||
| `daily-summary` — daily event summary email | 07:00 daily (`DAILY_SUMMARY_ENABLED=false` to disable) |
|
||||
| `waitlist` — expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false`) |
|
||||
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
|
||||
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
|
||||
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
|
||||
| `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) |
|
||||
| `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) |
|
||||
| `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
|
||||
|
||||
---
|
||||
|
||||
@@ -518,7 +567,16 @@ falling back to a general guide.
|
||||
set.
|
||||
- Unhandled promise rejections are logged and don't crash the server.
|
||||
- Backend status page (`/`) and built-in API docs page (`/docs`).
|
||||
- Jest tests for early-bird tranche pricing and donation balances.
|
||||
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
|
||||
end-to-end tests in `e2e/` (`npm run test:e2e`) that start their own backend and
|
||||
frontend against a separate, freshly seeded database: public pages, free and paid
|
||||
registration through to check-in and cashup, multi-day check-in, the attendance
|
||||
register and certificates, the event wizard, the kiosk, and the admin jobs page.
|
||||
- **CI** — `.gitea/workflows/ci.yml` runs the backend tests, frontend lint/typecheck/
|
||||
build and the end-to-end tests on every push (needs a Gitea Actions runner). A `v*`
|
||||
tag also deploys over SSH with `scripts/deploy.sh` once the `DEPLOY_*` secrets are set:
|
||||
it backs up the database, checks out the tag, migrates, builds and reloads PM2, and
|
||||
goes back to the previous version if a step fails before the reload.
|
||||
- Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
---
|
||||
@@ -539,9 +597,10 @@ falling back to a general guide.
|
||||
| `/setup` | First run | Setup wizard |
|
||||
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
|
||||
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
|
||||
| `/certificates/verify/[code]` | Public | Check a completion certificate |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/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
|
||||
|
||||
@@ -82,8 +81,26 @@ Documentation: see Frontend README for environment and scripts.
|
||||
- npm run dev
|
||||
- Next.js dev server runs at http://localhost:3000 by default; if it conflicts with backend, it will pick another port (e.g., 3001). Ensure NEXT_PUBLIC_API_URL points to the backend port.
|
||||
|
||||
## Tests
|
||||
|
||||
- Backend unit tests: `cd backend && npm test`
|
||||
- End-to-end tests (Playwright), from the repo root:
|
||||
- once: `npm install` and `npx playwright install chromium`
|
||||
- `npm run test:e2e`
|
||||
- They start their own backend (port 5100) and frontend (port 3100) against a separate
|
||||
database — `backend/.env`'s `DATABASE_URL` with `_e2e` added to the name, or
|
||||
`E2E_DATABASE_URL` — which is dropped, recreated and seeded on every run. Your own dev
|
||||
servers and database aren't touched. The database user needs permission to create
|
||||
databases.
|
||||
- CI: `.gitea/workflows/ci.yml` runs all of the above on every push once a Gitea Actions
|
||||
runner is registered. Pushing a `v*` tag also deploys with `scripts/deploy.sh` when the
|
||||
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY` (and optional `DEPLOY_PATH`) secrets are set.
|
||||
|
||||
## Deployment
|
||||
|
||||
To update a running install to a release: `bash scripts/deploy.sh v1.34.0` on the server
|
||||
(backs up the database, migrates, builds, reloads PM2).
|
||||
|
||||
### Backend Deployment
|
||||
|
||||
1. Provision a PostgreSQL database and set DATABASE_URL.
|
||||
|
||||
+10
@@ -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 |
|
||||
|
||||
@@ -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,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Coupons](#coupons)
|
||||
- [Parental consent](#parental-consent)
|
||||
- [Waitlist](#waitlist)
|
||||
- [Multi-day events & attendance](#multi-day-events--attendance)
|
||||
- [Offline scanning](#offline-scanning)
|
||||
- [Ticket transfer](#ticket-transfer)
|
||||
- [Rooms & groups](#rooms--groups)
|
||||
@@ -28,6 +29,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Post-event feedback](#post-event-feedback)
|
||||
- [Registration funnel](#registration-funnel)
|
||||
- [Impersonation](#impersonation)
|
||||
- [Background jobs](#background-jobs)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -285,6 +287,22 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
|
||||
---
|
||||
|
||||
## Background jobs
|
||||
|
||||
Timed jobs run on a pg-boss queue in Postgres (schema `pgboss`, created on first start) —
|
||||
see `backend/src/jobs/index.js`.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/admin/jobs` | admin | `{ mode: "queue" \| "timers" \| "off", timezone, jobs: [{ name, label, cron, enabled, envFlag, nextRun, recent: [{ state, startedOn, completedOn, output }] }] }` |
|
||||
| POST | `/api/admin/jobs/:name/run` | admin | Run a job now: `{ queued: true, id }` (or `{ queued: false, output }` in timer mode). Audit-logged as `job_run_manual` |
|
||||
|
||||
Env: `JOBS_ENABLED=false` turns every job off; each job's own flag (`WAITLIST_JOB_ENABLED`, …)
|
||||
turns it off; `JOB_SCHEDULE_<NAME>` (e.g. `JOB_SCHEDULE_DAILY_SUMMARY="0 6 * * *"`) changes its
|
||||
cron schedule; times are in `APP_TIMEZONE` (default Africa/Johannesburg).
|
||||
|
||||
---
|
||||
|
||||
## Impersonation
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
@@ -330,7 +348,7 @@ Data handling:
|
||||
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.
|
||||
- A job every 15 minutes (`feedback` job; `FEEDBACK_JOB_ENABLED`, `JOB_SCHEDULE_FEEDBACK`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
|
||||
|
||||
## Sponsor a spot
|
||||
|
||||
@@ -422,6 +440,64 @@ How syncing works:
|
||||
- Check-in messages are sent after the sync.
|
||||
- Locks, releases, force-releases and syncs are written to the admin audit log.
|
||||
|
||||
## Multi-day events & attendance
|
||||
|
||||
An event with `scheduleType: "sessions"` runs on several dates (e.g. every Wednesday for
|
||||
5 weeks). People register and pay once and get one ticket; check-in is per session.
|
||||
`startDate`/`endDate` always follow the first and last active session.
|
||||
|
||||
Create/update fields (`POST`/`PUT /api/events`):
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| `scheduleType` | `single` (default) or `sessions`. Switching back to `single` is refused once there are session check-ins |
|
||||
| `attendanceMode` | `whole` (default) or `pick` — people choose sessions. Not allowed with `requiresParentalConsent` |
|
||||
| `sessions` | `[{ id?, label?, startsAt, endsAt, location?, cancelled?, capacity? }]` — the full list. Rows left out are deleted (refused when they have check-ins — send `cancelled: true` instead). The event and its sessions save together, so a refused change saves nothing |
|
||||
| `lateJoinEnabled`, `lateJoinUntilSessionIndex` | Registration stays open until that session (index into the sent list, sorted by start) starts; otherwise it closes when the first session starts |
|
||||
| `latePricing`, `lateFixedPrice` | `full`, `prorata` (price × sessions left ÷ sessions, nearest R10, at least R10) or `fixed` (only ever lowers the price). Main tickets only |
|
||||
| `checkInWindow` | `whole_day` (default — any time on the session's date, in `APP_TIMEZONE`, default Africa/Johannesburg) or `session_only` (2 h before start until the end) |
|
||||
| `sessionReminders` | `null` (off) or `{ offsets: [60 \| 180 \| 1440], channels: ["email", "whatsapp"] }` |
|
||||
| `certificatesEnabled`, `certificateConfig` | `{ template: classic \| modern \| church \| kids, title, body ({name} {event} {sessionsAttended} {totalSessions} {endDate}), signatureName, signatureRole, signatureImage, rule: { type: all \| count \| percent, value }, sendOnIssue }` |
|
||||
|
||||
Event options take `sessionMode: "all" | "pick"` (`POST /api/events/:id/options`, `PUT /api/events/options/:id`).
|
||||
In a pick-mode event, a `pick` option is bought per session: registration lines send
|
||||
`sessionIds: [...]` (`POST /api/registrations`, `/manual`, coupon/bundle quotes), and each
|
||||
session becomes its own registration line (`RegistrationOption.sessionId`) and ticket.
|
||||
Capacity counts picked places plus whole-course main tickets; a pick option's
|
||||
`stockLimit` is per session. `PUT /api/registrations/:id/options` refuses pick options and
|
||||
leaves picked lines untouched. Pick-mode events stay open until the last session starts
|
||||
(`wholeCourseClosesAt` gives when whole-course tickets stop). Single-event responses add
|
||||
`booked` and `placesLeft` per session and `sessionAvailability` per pick option.
|
||||
|
||||
Responses for events include `sessions[]` (with `cancelled` and `hasCheckIns`),
|
||||
`registrationClosesAt`, `nextSession` and `lateJoin: { active, mode, sessionsLeft, totalSessions, fixedPrice }`.
|
||||
`PUT` also returns `sessionChanges: { added, moved, cancelled, restored, removed }`.
|
||||
|
||||
Scanning: `GET /api/tickets/scan-preview/:qr?sessionId=` and `POST /api/tickets/scan/:qr`
|
||||
(`{ sessionId? }`) default to today's session and return `session` plus `remaining` for it;
|
||||
`sessionError` / 400 `NO_SESSION` when no session is open for check-in. Offline snapshots
|
||||
are for the session running when downloaded; synced scans count for the session at their scan time.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/attendance/:eventId` | staff+ | Register: `columns` (sessions), `rows` (one per main ticket: `checkIns` per session, `attended`, `total`, `completed`, `certificate`), `totals` |
|
||||
| GET | `/api/attendance/:eventId/export?format=xlsx\|pdf` | staff+ | Download the register |
|
||||
| POST | `/api/attendance/:eventId/mark` | supervisor+ | `{ ticketId, sessionId, present }` — single-date events use `sessionId: "event"` |
|
||||
| POST | `/api/attendance/:eventId/certificates` | supervisor+ | Issue now to everyone eligible, or `{ ticketId }` for one (even if not eligible) |
|
||||
| POST | `/api/attendance/:eventId/certificates/send` | supervisor+ | Send unsent certificates, or `{ certificateId }` |
|
||||
| GET | `/api/attendance/:eventId/certificates.pdf` | supervisor+ | All certificates in one PDF |
|
||||
| POST | `/api/attendance/certificates/preview` | supervisor+ | Sample PDF: `{ title, config }` |
|
||||
| POST | `/api/attendance/certificates/:id/revoke` | supervisor+ | Revoke (never re-issued automatically) |
|
||||
| GET | `/api/attendance/certificates/mine` | user+ | My certificates |
|
||||
| GET | `/api/attendance/certificates/:id/pdf` | user+ | Download one (its person, the booker, or a supervisor) |
|
||||
| GET | `/api/attendance/certificates/verify/:code` | public | `{ valid, revoked?, name, event, completedOn, issuedAt }` |
|
||||
|
||||
A job every 5 minutes (`sessions` job; `SESSION_JOBS_ENABLED`, `JOB_SCHEDULE_SESSIONS`) sends due session
|
||||
reminders (each once; when several are due at once only the closest is sent) and issues
|
||||
certificates for events with certificates on that ended in the last 14 days.
|
||||
|
||||
---
|
||||
|
||||
## Waitlist
|
||||
|
||||
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
|
||||
@@ -440,7 +516,7 @@ Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitl
|
||||
How offers work:
|
||||
- Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks.
|
||||
- Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit.
|
||||
- A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers.
|
||||
- A job every 10 minutes (`waitlist` job; `WAITLIST_JOB_ENABLED`, `JOB_SCHEDULE_WAITLIST`) expires unclaimed offers and re-offers.
|
||||
- Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer.
|
||||
- Manual registration and adding places to an existing booking are now checked against stock too.
|
||||
|
||||
@@ -706,7 +782,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.
|
||||
|
||||
@@ -718,6 +794,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
+1731
-420
File diff suppressed because it is too large
Load Diff
+16
-10
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.24.0",
|
||||
"version": "1.35.0",
|
||||
"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,14 +13,17 @@
|
||||
"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",
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^4.9.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.18.2",
|
||||
@@ -31,22 +34,25 @@
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^10.0.16",
|
||||
"pdfkit": "^0.17.1",
|
||||
"pg-boss": "^10.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"raw-body": "^3.0.0",
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"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,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;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScheduleType" AS ENUM ('single', 'sessions');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AttendanceMode" AS ENUM ('whole', 'pick');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LatePricing" AS ENUM ('full', 'prorata', 'fixed');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CheckInWindow" AS ENUM ('whole_day', 'session_only');
|
||||
|
||||
-- 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 'event_sessions_changed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'attendance_marked';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'attendance_unmarked';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'certificate_issued';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'certificate_revoked';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "attendanceMode" "AttendanceMode" NOT NULL DEFAULT 'whole',
|
||||
ADD COLUMN "certificateConfig" JSONB,
|
||||
ADD COLUMN "certificatesEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "checkInWindow" "CheckInWindow" NOT NULL DEFAULT 'whole_day',
|
||||
ADD COLUMN "lateFixedPrice" DOUBLE PRECISION,
|
||||
ADD COLUMN "lateJoinEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "lateJoinUntilSessionId" TEXT,
|
||||
ADD COLUMN "latePricing" "LatePricing" NOT NULL DEFAULT 'full',
|
||||
ADD COLUMN "scheduleType" "ScheduleType" NOT NULL DEFAULT 'single',
|
||||
ADD COLUMN "sessionReminders" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TicketUsage" ADD COLUMN "manual" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "sessionId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"label" TEXT,
|
||||
"startsAt" TIMESTAMP(3) NOT NULL,
|
||||
"endsAt" TIMESTAMP(3) NOT NULL,
|
||||
"location" TEXT,
|
||||
"cancelledAt" TIMESTAMP(3),
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "EventSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SessionReminderLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"offset" INTEGER NOT NULL,
|
||||
"sent" INTEGER NOT NULL DEFAULT 0,
|
||||
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SessionReminderLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Certificate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"sessionsAttended" INTEGER NOT NULL DEFAULT 0,
|
||||
"totalSessions" INTEGER NOT NULL DEFAULT 0,
|
||||
"manual" BOOLEAN NOT NULL DEFAULT false,
|
||||
"issuedById" TEXT,
|
||||
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"emailedAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Certificate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventSession_eventId_startsAt_idx" ON "EventSession"("eventId", "startsAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SessionReminderLog_sessionId_offset_key" ON "SessionReminderLog"("sessionId", "offset");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Certificate_code_key" ON "Certificate"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Certificate_userId_idx" ON "Certificate"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Certificate_ticketId_idx" ON "Certificate"("ticketId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Certificate_issuedById_idx" ON "Certificate"("issuedById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Certificate_eventId_ticketId_key" ON "Certificate"("eventId", "ticketId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketUsage_sessionId_idx" ON "TicketUsage"("sessionId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventSession" ADD CONSTRAINT "EventSession_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SessionReminderLog" ADD CONSTRAINT "SessionReminderLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_issuedById_fkey" FOREIGN KEY ("issuedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OptionSessionMode" AS ENUM ('all', 'pick');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventOption" ADD COLUMN "sessionMode" "OptionSessionMode" NOT NULL DEFAULT 'all';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventSession" ADD COLUMN "capacity" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "RegistrationOption" ADD COLUMN "sessionId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationOption_sessionId_idx" ON "RegistrationOption"("sessionId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'job_run_manual';
|
||||
+288
-13
@@ -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")
|
||||
@@ -119,6 +119,8 @@ model User {
|
||||
|
||||
twoFactorChallenges TwoFactorChallenge[]
|
||||
trustedDevices TrustedDevice[]
|
||||
certificates Certificate[]
|
||||
certificatesIssued Certificate[] @relation("CertificateIssuedBy")
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -197,6 +199,29 @@ model Event {
|
||||
consentRequests ConsentRequest[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
scanLocks ScanLock[]
|
||||
bundles EventBundle[]
|
||||
|
||||
// Multi-day events: `sessions` is one event running on several dates (e.g. every
|
||||
// Wednesday for 5 weeks). startDate/endDate are kept in sync with the first session's
|
||||
// start and the last session's end (see utils/sessions.js), so code that only reads
|
||||
// them keeps working.
|
||||
scheduleType ScheduleType @default(single)
|
||||
attendanceMode AttendanceMode @default(whole)
|
||||
// Late joining: registration stays open until session `lateJoinUntilSessionId` starts
|
||||
// (otherwise it closes when the first session starts). Late pricing applies to main
|
||||
// tickets only.
|
||||
lateJoinEnabled Boolean @default(false)
|
||||
lateJoinUntilSessionId String?
|
||||
latePricing LatePricing @default(full)
|
||||
lateFixedPrice Float?
|
||||
checkInWindow CheckInWindow @default(whole_day)
|
||||
// null = off; { offsets: [minutes before start], channels: ["email", "whatsapp"] }
|
||||
sessionReminders Json?
|
||||
certificatesEnabled Boolean @default(false)
|
||||
// { template, title, body, signatureName, signatureRole, signatureImage, rule: { type, value } }
|
||||
certificateConfig Json?
|
||||
sessions EventSession[]
|
||||
certificates Certificate[]
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@ -212,6 +237,9 @@ model EventOption {
|
||||
stockLimit Int @default(0)
|
||||
// Applied automatically for Staff and above instead of `price` (when lower). Null = no staff price.
|
||||
staffPrice Float?
|
||||
// Multi-day events in "pick sessions" mode: `pick` options are bought per session
|
||||
// (price is per session, stockLimit is places per session); `all` options cover every session.
|
||||
sessionMode OptionSessionMode @default(all)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
registrationOptions RegistrationOption[]
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
@@ -220,6 +248,7 @@ model EventOption {
|
||||
costs EventCost[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -255,6 +284,7 @@ model OptionVariant {
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
@@ -265,10 +295,13 @@ 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?
|
||||
@@ -281,6 +314,7 @@ model Registration {
|
||||
formResponses FormResponse[]
|
||||
formDrafts FormDraft[]
|
||||
couponRedemptions CouponRedemption[]
|
||||
bundleApplications RegistrationBundle[]
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@ -306,11 +340,16 @@ model RegistrationOption {
|
||||
tickets Ticket[]
|
||||
tranches RegistrationOptionTranche[]
|
||||
attendees Attendee[]
|
||||
// A "pick sessions" option: one line (and ticket) per session booked. Null for
|
||||
// everything else.
|
||||
sessionId String?
|
||||
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([eventOptionId])
|
||||
@@index([variantId])
|
||||
@@index([appliedTierId])
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
|
||||
@@ -391,6 +430,7 @@ model Ticket {
|
||||
usages TicketUsage[]
|
||||
transfers TicketTransfer[]
|
||||
transferRequests TicketTransferRequest[]
|
||||
certificates Certificate[]
|
||||
|
||||
@@index([holderUserId])
|
||||
@@index([eventId])
|
||||
@@ -411,6 +451,11 @@ model TicketUsage {
|
||||
offline Boolean @default(false)
|
||||
scanLockId String?
|
||||
syncedAt DateTime?
|
||||
// Multi-day events: the session this check-in counts for (null on single-date events).
|
||||
sessionId String?
|
||||
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
|
||||
// Ticked present by a supervisor from the attendance register, not scanned.
|
||||
manual Boolean @default(false)
|
||||
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)
|
||||
@@ -418,6 +463,7 @@ model TicketUsage {
|
||||
@@index([ticketId])
|
||||
@@index([scannedAt])
|
||||
@@index([scannedById, scannedAt])
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
model PasswordReset {
|
||||
@@ -495,6 +541,9 @@ enum AdminAuditAction {
|
||||
coupon_updated
|
||||
coupon_disabled
|
||||
coupon_applied_manual
|
||||
bundle_created
|
||||
bundle_updated
|
||||
bundle_deleted
|
||||
consent_override
|
||||
consent_rerequested
|
||||
medical_list_viewed
|
||||
@@ -505,6 +554,15 @@ enum AdminAuditAction {
|
||||
ticket_transferred
|
||||
impersonation_started
|
||||
impersonation_ended
|
||||
yoco_config_updated
|
||||
yoco_webhook_registered
|
||||
yoco_webhook_deleted
|
||||
event_sessions_changed
|
||||
attendance_marked
|
||||
attendance_unmarked
|
||||
certificate_issued
|
||||
certificate_revoked
|
||||
job_run_manual
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -706,20 +764,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
|
||||
@@ -851,6 +957,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 {
|
||||
@@ -1251,3 +1436,93 @@ model OfflineSyncReport {
|
||||
@@index([scanLockId])
|
||||
@@index([reviewedById])
|
||||
}
|
||||
|
||||
enum ScheduleType {
|
||||
single
|
||||
sessions
|
||||
}
|
||||
|
||||
enum AttendanceMode {
|
||||
whole
|
||||
pick
|
||||
}
|
||||
|
||||
enum LatePricing {
|
||||
full
|
||||
prorata
|
||||
fixed
|
||||
}
|
||||
|
||||
enum OptionSessionMode {
|
||||
all
|
||||
pick
|
||||
}
|
||||
|
||||
enum CheckInWindow {
|
||||
whole_day
|
||||
session_only
|
||||
}
|
||||
|
||||
// One date/time of a multi-day event.
|
||||
model EventSession {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
label String?
|
||||
startsAt DateTime
|
||||
endsAt DateTime
|
||||
location String?
|
||||
// Cancelled sessions are kept (they may have check-ins) but hidden from the schedule
|
||||
// and left out of attendance totals.
|
||||
cancelledAt DateTime?
|
||||
order Int @default(0)
|
||||
// Optional limit on people at this session (picked places + whole-course tickets).
|
||||
capacity Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
usages TicketUsage[]
|
||||
reminders SessionReminderLog[]
|
||||
registrationOptions RegistrationOption[]
|
||||
|
||||
@@index([eventId, startsAt])
|
||||
}
|
||||
|
||||
// Which session reminders have gone out, so the job sends each one once.
|
||||
model SessionReminderLog {
|
||||
id String @id @default(uuid())
|
||||
sessionId String
|
||||
// Minutes before the session start this reminder is for.
|
||||
offset Int
|
||||
sent Int @default(0)
|
||||
sentAt DateTime @default(now())
|
||||
session EventSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, offset])
|
||||
}
|
||||
|
||||
// A completion certificate for one ticket (one person).
|
||||
model Certificate {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
ticketId String
|
||||
userId String?
|
||||
name String
|
||||
// Short code printed on the certificate and checked on the public verify page.
|
||||
code String @unique
|
||||
sessionsAttended Int @default(0)
|
||||
totalSessions Int @default(0)
|
||||
manual Boolean @default(false)
|
||||
issuedById String?
|
||||
issuedAt DateTime @default(now())
|
||||
emailedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
issuedBy User? @relation("CertificateIssuedBy", fields: [issuedById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([eventId, ticketId])
|
||||
@@index([userId])
|
||||
@@index([ticketId])
|
||||
@@index([issuedById])
|
||||
}
|
||||
|
||||
@@ -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,408 @@
|
||||
const ExcelJS = require('exceljs');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const sessionsUtil = require('../utils/sessions');
|
||||
const certificates = require('../utils/certificates');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
const notFound = (msg) => Object.assign(new Error(msg), { statusCode: 404 });
|
||||
const badRequest = (msg) => Object.assign(new Error(msg), { statusCode: 400 });
|
||||
|
||||
// Single-date events have no sessions; the register treats the event itself as one
|
||||
// column with this id (its check-ins have no sessionId).
|
||||
const WHOLE_EVENT = 'event';
|
||||
|
||||
/**
|
||||
* The attendance register for an event: columns (sessions), one row per main ticket with
|
||||
* check-ins per session, and certificate status.
|
||||
*/
|
||||
async function buildRegister(eventId) {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { sessions: { orderBy: { startsAt: 'asc' } } },
|
||||
});
|
||||
if (!event) throw notFound('Event not found');
|
||||
const multi = event.scheduleType === 'sessions';
|
||||
const active = sessionsUtil.activeSessions(event.sessions);
|
||||
const columns = multi
|
||||
? event.sessions.map(s => ({
|
||||
id: s.id, label: s.cancelledAt ? (s.label || 'Session') : sessionsUtil.sessionLabel(s, event.sessions),
|
||||
startsAt: s.startsAt, endsAt: s.endsAt, cancelled: !!s.cancelledAt,
|
||||
}))
|
||||
: [{ id: WHOLE_EVENT, label: 'Checked in', startsAt: event.startDate, endsAt: event.endDate, cancelled: false }];
|
||||
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: { eventId, registrationOption: { eventOption: { isMainTicket: true }, registration: { status: { not: 'cancelled' } } } },
|
||||
include: {
|
||||
attendee: { select: { name: true } },
|
||||
holder: { select: { name: true } },
|
||||
user: { select: { name: true, email: true, phoneNumber: true } },
|
||||
usages: { select: { quantityRedeemed: true, sessionId: true, manual: true } },
|
||||
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
|
||||
certificates: { select: { id: true, code: true, issuedAt: true, revokedAt: true, manual: true, emailedAt: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const activeIds = new Set(active.map(s => s.id));
|
||||
const config = sessionsUtil.normalizeCertificateConfig(event.certificateConfig);
|
||||
// A picked-session booking (one ticket per session) shows as one row.
|
||||
const rows = sessionsUtil.groupTickets(tickets).map(g => {
|
||||
const t = g.tickets[0];
|
||||
const bySession = {};
|
||||
for (const tk of g.tickets) {
|
||||
for (const u of tk.usages) {
|
||||
const key = multi ? u.sessionId : WHOLE_EVENT;
|
||||
if (!key) continue;
|
||||
bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1);
|
||||
}
|
||||
}
|
||||
const booked = g.sessionIds ? g.sessionIds.filter(id => activeIds.has(id)) : null;
|
||||
const counted = booked ? new Set(booked) : activeIds;
|
||||
const attended = multi ? Object.keys(bySession).filter(k => counted.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0);
|
||||
const total = multi ? counted.size : 1;
|
||||
const cert = t.certificates[0] || null;
|
||||
return {
|
||||
ticketId: t.id,
|
||||
// Picked sessions: which sessions this person booked, and the ticket for each.
|
||||
bookedSessions: g.sessionIds,
|
||||
sessionTickets: g.sessionIds ? Object.fromEntries(g.tickets.map(tk => [tk.registrationOption.sessionId, tk.id])) : null,
|
||||
name: t.attendee?.name || t.holder?.name || t.user?.name || '',
|
||||
bookedBy: t.attendee || t.holder ? t.user?.name || '' : '',
|
||||
email: t.user?.email || '',
|
||||
phone: t.user?.phoneNumber || '',
|
||||
option: t.registrationOption?.eventOption?.name || '',
|
||||
variant: t.registrationOption?.variant?.name || null,
|
||||
quantity: t.quantity || 1,
|
||||
checkIns: bySession,
|
||||
attended,
|
||||
total,
|
||||
completed: sessionsUtil.completionMet(config.rule, attended, total),
|
||||
certificate: cert,
|
||||
};
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const totals = Object.fromEntries(columns.map(c => [c.id, rows.reduce((n, r) => n + (r.checkIns[c.id] ? 1 : 0), 0)]));
|
||||
return {
|
||||
event: {
|
||||
id: event.id, title: event.title, scheduleType: event.scheduleType, startDate: event.startDate, endDate: event.endDate,
|
||||
cashupStatus: event.cashupStatus, certificatesEnabled: event.certificatesEnabled, certificateConfig: config,
|
||||
},
|
||||
columns,
|
||||
rows,
|
||||
totals,
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Attendance register (sessions × attendees)
|
||||
// @route GET /api/attendance/:eventId
|
||||
// @access Private/Staff
|
||||
const getRegister = async (req, res) => {
|
||||
try {
|
||||
res.json(await buildRegister(req.params.eventId));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Tick someone present or absent for a session (e.g. let in without a scan)
|
||||
// @route POST /api/attendance/:eventId/mark body: { ticketId, sessionId, present }
|
||||
// @access Private/Supervisor
|
||||
const markAttendance = async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { ticketId, present } = req.body || {};
|
||||
const sessionKey = String(req.body?.sessionId || '');
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: String(ticketId || '') }, include: { usages: true, event: { select: { scheduleType: true, cashupStatus: true } }, registrationOption: { select: { sessionId: true } } } });
|
||||
if (!ticket || ticket.eventId !== eventId) throw notFound('Ticket not found for this event');
|
||||
if (ticket.event.cashupStatus === 'closed') throw badRequest('This event is closed — reopen it to change attendance.');
|
||||
const multi = ticket.event.scheduleType === 'sessions';
|
||||
let sessionId = null;
|
||||
if (multi) {
|
||||
const session = await prisma.eventSession.findUnique({ where: { id: sessionKey } });
|
||||
if (!session || session.eventId !== eventId) throw notFound('Session not found for this event');
|
||||
if (session.cancelledAt) throw badRequest('That session was cancelled');
|
||||
if (ticket.registrationOption?.sessionId && ticket.registrationOption.sessionId !== session.id) throw badRequest('That ticket is for a different session');
|
||||
sessionId = session.id;
|
||||
} else if (sessionKey !== WHOLE_EVENT) {
|
||||
throw badRequest('This event has no sessions');
|
||||
}
|
||||
|
||||
const forSession = ticket.usages.filter(u => (u.sessionId || null) === sessionId);
|
||||
if (present === true) {
|
||||
const redeemed = forSession.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const left = (ticket.quantity || 1) - redeemed;
|
||||
if (left > 0) {
|
||||
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: req.user.id, quantityRedeemed: left, sessionId, manual: true } });
|
||||
if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: true, updatedAt: new Date() } });
|
||||
}
|
||||
} else {
|
||||
if (forSession.length > 0) {
|
||||
await prisma.ticketUsage.deleteMany({ where: { id: { in: forSession.map(u => u.id) } } });
|
||||
if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: false, updatedAt: new Date() } });
|
||||
}
|
||||
}
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role,
|
||||
action: present === true ? 'attendance_marked' : 'attendance_unmarked',
|
||||
targetType: 'Ticket', targetId: ticket.id,
|
||||
metadata: { eventId, sessionId, removedScans: present === true ? 0 : forSession.length },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
function registerFileName(reg, ext) {
|
||||
return `Attendance - ${reg.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80) + `.${ext}`;
|
||||
}
|
||||
|
||||
function fmtColumnDate(d) {
|
||||
return new Date(d).toLocaleDateString('en-GB', { timeZone: sessionsUtil.TIMEZONE, day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
// @desc Download the attendance register as Excel or PDF
|
||||
// @route GET /api/attendance/:eventId/export?format=xlsx|pdf
|
||||
// @access Private/Staff
|
||||
const exportRegister = async (req, res) => {
|
||||
try {
|
||||
const reg = await buildRegister(req.params.eventId);
|
||||
const cols = reg.columns.filter(c => !c.cancelled);
|
||||
if (req.query.format === 'pdf') {
|
||||
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 36 });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${registerFileName(reg, 'pdf')}"`);
|
||||
doc.pipe(res);
|
||||
doc.font('Helvetica-Bold').fontSize(16).text(`Attendance — ${reg.event.title}`);
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#64748b').text(`${reg.rows.length} attendee(s) · printed ${new Date().toLocaleString('en-GB', { timeZone: sessionsUtil.TIMEZONE })}`);
|
||||
doc.moveDown(0.6);
|
||||
const nameW = 170;
|
||||
const totalW = 50;
|
||||
const usable = doc.page.width - 72 - nameW - totalW;
|
||||
const colW = Math.max(28, Math.min(70, usable / Math.max(1, cols.length)));
|
||||
// Header height fits the longest session label.
|
||||
const headText = (c) => `${c.label}\n${fmtColumnDate(c.startsAt)}`;
|
||||
doc.font('Helvetica-Bold').fontSize(8);
|
||||
const headH = Math.max(12, ...cols.map(c => doc.heightOfString(headText(c), { width: colW - 2 })));
|
||||
const header = () => {
|
||||
const y = doc.y;
|
||||
doc.font('Helvetica-Bold').fontSize(8).fillColor('#1e293b').text('Name', 36, y + headH - 10, { width: nameW });
|
||||
cols.forEach((c, i) => doc.text(headText(c), 36 + nameW + i * colW, y, { width: colW - 2, align: 'center' }));
|
||||
doc.text('Total', 36 + nameW + cols.length * colW, y + headH - 10, { width: totalW, align: 'center' });
|
||||
doc.y = y + headH + 6;
|
||||
doc.moveTo(36, doc.y - 3).lineTo(doc.page.width - 36, doc.y - 3).lineWidth(0.5).strokeColor('#cbd5e1').stroke();
|
||||
doc.y += 2;
|
||||
};
|
||||
// The built-in PDF fonts have no ✓, so the tick is drawn.
|
||||
const tick = (cx, cy) => {
|
||||
doc.save().lineWidth(1.4).strokeColor('#15803d')
|
||||
.moveTo(cx - 3.5, cy).lineTo(cx - 1, cy + 2.8).lineTo(cx + 4, cy - 3).stroke().restore();
|
||||
};
|
||||
header();
|
||||
for (const r of reg.rows) {
|
||||
if (doc.y > doc.page.height - 50) { doc.addPage(); header(); }
|
||||
const y = doc.y;
|
||||
doc.font('Helvetica').fontSize(8.5).fillColor('#1e293b').text(r.name + (r.quantity > 1 ? ` (×${r.quantity})` : ''), 36, y, { width: nameW, ellipsis: true, lineBreak: false });
|
||||
cols.forEach((c, i) => {
|
||||
const qty = r.checkIns[c.id] || 0;
|
||||
const x = 36 + nameW + i * colW;
|
||||
const notBooked = r.bookedSessions && !r.bookedSessions.includes(c.id);
|
||||
if (qty && r.quantity <= 1) tick(x + (colW - 2) / 2, y + 4);
|
||||
else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : notBooked ? '-' : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false });
|
||||
});
|
||||
doc.fillColor('#1e293b').text(`${r.attended}/${r.total}`, 36 + nameW + cols.length * colW, y, { width: totalW, align: 'center', lineBreak: false });
|
||||
doc.y = y + 14;
|
||||
}
|
||||
doc.moveDown(0.5);
|
||||
const y = doc.y;
|
||||
doc.font('Helvetica-Bold').fontSize(8.5).text('Present', 36, y, { width: nameW });
|
||||
cols.forEach((c, i) => doc.text(String(reg.totals[c.id] || 0), 36 + nameW + i * colW, y, { width: colW - 2, align: 'center', lineBreak: false }));
|
||||
doc.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet('Attendance');
|
||||
ws.addRow(['Name', 'Booked by', 'Ticket', 'Email', 'Phone', 'Places', ...cols.map(c => `${c.label} (${fmtColumnDate(c.startsAt)})`), 'Attended', 'Completed', 'Certificate']);
|
||||
ws.getRow(1).font = { bold: true };
|
||||
for (const r of reg.rows) {
|
||||
ws.addRow([
|
||||
r.name, r.bookedBy, r.variant ? `${r.option} — ${r.variant}` : r.option, r.email, r.phone, r.quantity,
|
||||
...cols.map(c => r.checkIns[c.id] ? (r.quantity > 1 ? r.checkIns[c.id] : '✓') : (r.bookedSessions && !r.bookedSessions.includes(c.id) ? 'not booked' : '')),
|
||||
`${r.attended}/${r.total}`, r.completed ? 'Yes' : 'No',
|
||||
r.certificate && !r.certificate.revokedAt ? r.certificate.code : '',
|
||||
]);
|
||||
}
|
||||
ws.addRow([]);
|
||||
ws.addRow(['Present', '', '', '', '', '', ...cols.map(c => reg.totals[c.id] || 0)]).font = { bold: true };
|
||||
ws.columns.forEach((col, i) => { col.width = i < 5 ? 22 : 14; });
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${registerFileName(reg, 'xlsx')}"`);
|
||||
await wb.xlsx.write(res);
|
||||
res.end();
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Certificates (supervisor) ────────────────────────────────────────────────
|
||||
|
||||
async function assertCertificatesOn(eventId) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true, endDate: true, certificatesEnabled: true, certificateConfig: true } });
|
||||
if (!event) throw notFound('Event not found');
|
||||
if (!event.certificatesEnabled) throw badRequest('Certificates are switched off for this event (Edit event → Attendance).');
|
||||
return event;
|
||||
}
|
||||
|
||||
// @desc Issue certificates now — to everyone eligible, or one ticket (even if not eligible)
|
||||
// @route POST /api/attendance/:eventId/certificates body: { ticketId? }
|
||||
// @access Private/Supervisor
|
||||
const issueCertificates = async (req, res) => {
|
||||
try {
|
||||
await assertCertificatesOn(req.params.eventId);
|
||||
const created = req.body?.ticketId
|
||||
? [await certificates.issueManual(req.params.eventId, String(req.body.ticketId), req.user.id)]
|
||||
: await certificates.issueEligible(req.params.eventId, { issuedById: req.user.id });
|
||||
for (const c of created) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'certificate_issued', targetType: 'Certificate', targetId: c.id, metadata: { eventId: req.params.eventId, ticketId: c.ticketId, manual: !!req.body?.ticketId }, ip: getClientIp(req) });
|
||||
}
|
||||
res.json({ issued: created.length });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Revoke a certificate (the automatic job won't issue it again)
|
||||
// @route POST /api/attendance/certificates/:id/revoke
|
||||
// @access Private/Supervisor
|
||||
const revokeCertificate = async (req, res) => {
|
||||
try {
|
||||
const cert = await prisma.certificate.findUnique({ where: { id: req.params.id } });
|
||||
if (!cert) throw notFound('Certificate not found');
|
||||
await prisma.certificate.update({ where: { id: cert.id }, data: { revokedAt: new Date() } });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'certificate_revoked', targetType: 'Certificate', targetId: cert.id, metadata: { eventId: cert.eventId, ticketId: cert.ticketId }, ip: getClientIp(req) });
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Email/WhatsApp certificates that haven't been sent yet (or one, again)
|
||||
// @route POST /api/attendance/:eventId/certificates/send body: { certificateId? }
|
||||
// @access Private/Supervisor
|
||||
const sendCertificatesHandler = async (req, res) => {
|
||||
try {
|
||||
await assertCertificatesOn(req.params.eventId);
|
||||
const where = req.body?.certificateId
|
||||
? { id: String(req.body.certificateId), eventId: req.params.eventId }
|
||||
: { eventId: req.params.eventId, emailedAt: null };
|
||||
const ids = (await prisma.certificate.findMany({ where: { ...where, revokedAt: null }, select: { id: true } })).map(c => c.id);
|
||||
const sent = await certificates.sendCertificates(ids);
|
||||
res.json({ sent, skipped: ids.length - sent });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc All of an event's certificates in one PDF (one page each)
|
||||
// @route GET /api/attendance/:eventId/certificates.pdf
|
||||
// @access Private/Supervisor
|
||||
const downloadEventCertificates = async (req, res) => {
|
||||
try {
|
||||
const event = await assertCertificatesOn(req.params.eventId);
|
||||
const certs = await prisma.certificate.findMany({ where: { eventId: event.id, revokedAt: null }, orderBy: { name: 'asc' } });
|
||||
if (certs.length === 0) throw badRequest('No certificates have been issued for this event yet.');
|
||||
const pdf = await certificates.renderCertificatesPdf(certs, event);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${`Certificates - ${event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`);
|
||||
res.send(pdf);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Sample certificate with the editor's (unsaved) settings
|
||||
// @route POST /api/attendance/certificates/preview body: { title, config }
|
||||
// @access Private/Supervisor
|
||||
const previewCertificate = async (req, res) => {
|
||||
try {
|
||||
const event = { title: String(req.body?.title || 'Your event').slice(0, 200), endDate: new Date() };
|
||||
const pdf = await certificates.renderCertificatesPdf([certificates.sampleCertificate()], event, req.body?.config || {});
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', 'inline; filename="certificate-preview.pdf"');
|
||||
res.send(pdf);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Certificates (attendees, public) ─────────────────────────────────────────
|
||||
|
||||
// @desc The logged-in user's certificates
|
||||
// @route GET /api/attendance/certificates/mine
|
||||
// @access Private
|
||||
const myCertificates = async (req, res) => {
|
||||
try {
|
||||
const certs = await prisma.certificate.findMany({
|
||||
where: { revokedAt: null, OR: [{ userId: req.user.id }, { ticket: { userId: req.user.id } }] },
|
||||
include: { event: { select: { id: true, title: true, endDate: true } } },
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
});
|
||||
res.json(certs.map(c => ({ id: c.id, code: c.code, name: c.name, issuedAt: c.issuedAt, sessionsAttended: c.sessionsAttended, totalSessions: c.totalSessions, event: c.event })));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Download one certificate (its person, the booker, or a supervisor)
|
||||
// @route GET /api/attendance/certificates/:id/pdf
|
||||
// @access Private
|
||||
const downloadCertificate = async (req, res) => {
|
||||
try {
|
||||
const cert = await prisma.certificate.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { ticket: { select: { userId: true } }, event: { select: { id: true, title: true, endDate: true, certificateConfig: true } } },
|
||||
});
|
||||
const allowed = cert && (SUPERVISORS.includes(req.user.role) || cert.userId === req.user.id || cert.ticket?.userId === req.user.id);
|
||||
if (!allowed || cert.revokedAt) throw notFound('Certificate not found');
|
||||
const pdf = await certificates.renderCertificatesPdf([cert], cert.event);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${`Certificate - ${cert.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`);
|
||||
res.send(pdf);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Check a certificate code (public verify page). Shows only name, event and date.
|
||||
// @route GET /api/attendance/certificates/verify/:code
|
||||
// @access Public
|
||||
const verifyCertificate = async (req, res) => {
|
||||
try {
|
||||
const code = String(req.params.code || '').trim().toUpperCase().slice(0, 20);
|
||||
const cert = await prisma.certificate.findUnique({ where: { code }, include: { event: { select: { title: true, endDate: true } } } });
|
||||
if (!cert) return res.json({ valid: false });
|
||||
res.json({
|
||||
valid: !cert.revokedAt,
|
||||
revoked: !!cert.revokedAt,
|
||||
name: cert.name,
|
||||
event: cert.event.title,
|
||||
completedOn: cert.event.endDate,
|
||||
issuedAt: cert.issuedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getRegister, markAttendance, exportRegister,
|
||||
issueCertificates, revokeCertificate, sendCertificatesHandler, downloadEventCertificates, previewCertificate,
|
||||
myCertificates, downloadCertificate, verifyCertificate,
|
||||
};
|
||||
@@ -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,41 @@ 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 = [];
|
||||
// A "pick sessions" line counts once per chosen session.
|
||||
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
|
||||
for (const o of expanded) {
|
||||
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 +285,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 +302,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 +316,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 +339,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 };
|
||||
|
||||
@@ -7,6 +7,85 @@ const crypto = require('crypto');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const sessionsUtil = require('../utils/sessions');
|
||||
|
||||
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
|
||||
|
||||
// Adds the multi-day schedule to an event for API responses: sessions (cancelled ones
|
||||
// flagged), when registration closes, the next session, and late-joiner pricing.
|
||||
// `bookings` (Map sessionId → people booked) adds `booked` and `placesLeft` per session.
|
||||
function withSchedule(ev, bookings = null) {
|
||||
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({
|
||||
...s,
|
||||
cancelled: !!s.cancelledAt,
|
||||
hasCheckIns: (_count?.usages || 0) > 0,
|
||||
...(bookings ? { booked: bookings.get(s.id) || 0, placesLeft: s.capacity > 0 ? Math.max(0, s.capacity - (bookings.get(s.id) || 0)) : null } : {}),
|
||||
}));
|
||||
const active = sessionsUtil.activeSessions(sessions);
|
||||
const now = new Date();
|
||||
return {
|
||||
...ev,
|
||||
sessions,
|
||||
registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions),
|
||||
wholeCourseClosesAt: ev.scheduleType === 'sessions' ? sessionsUtil.wholeCourseClosesAt(ev, sessions) : null,
|
||||
nextSession: ev.scheduleType === 'sessions' ? active.find(s => new Date(s.endsAt) > now) || null : null,
|
||||
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// The schedule/attendance fields of an event from a create/update body. `current` is the
|
||||
// saved event on update (unsent fields keep their value).
|
||||
function scheduleData(body, current = {}) {
|
||||
const has = (k) => body[k] !== undefined;
|
||||
const data = {};
|
||||
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
|
||||
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
|
||||
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
|
||||
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
|
||||
}
|
||||
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
|
||||
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
|
||||
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
|
||||
if (has('lateFixedPrice')) {
|
||||
const n = parseFloat(body.lateFixedPrice);
|
||||
data.lateFixedPrice = body.lateFixedPrice === null || body.lateFixedPrice === '' || isNaN(n) ? null : Math.max(0, n);
|
||||
}
|
||||
if (has('sessionReminders')) data.sessionReminders = sessionsUtil.normalizeReminders(body.sessionReminders) ?? null;
|
||||
if (has('certificatesEnabled')) data.certificatesEnabled = body.certificatesEnabled === true;
|
||||
if (has('certificateConfig')) data.certificateConfig = sessionsUtil.normalizeCertificateConfig(body.certificateConfig);
|
||||
const latePricing = data.latePricing ?? current.latePricing;
|
||||
const lateFixedPrice = data.lateFixedPrice !== undefined ? data.lateFixedPrice : current.lateFixedPrice;
|
||||
if (latePricing === 'fixed' && (lateFixedPrice === null || lateFixedPrice === undefined) && (data.lateJoinEnabled ?? current.lateJoinEnabled)) {
|
||||
throw sessionsUtil.sessionError('Enter the fixed late price, or choose another late price option');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the session list and the "late joining until" session after an event is created
|
||||
* or updated, inside the caller's transaction (`tx`) so a refused change saves nothing.
|
||||
* `lateJoinUntilSessionIndex` points into the sent session list (sorted by start).
|
||||
* @returns {Promise<object|null>} the change counts, or null when nothing was sent
|
||||
*/
|
||||
async function saveSchedule(tx, eventId, body) {
|
||||
sessionsUtil.forgetSchedule(eventId);
|
||||
if (body.scheduleType === 'single') {
|
||||
const used = await tx.ticketUsage.count({ where: { session: { eventId } } });
|
||||
if (used > 0) throw sessionsUtil.sessionError("This event already has session check-ins, so it can't go back to a single date.");
|
||||
await tx.eventSession.deleteMany({ where: { eventId } });
|
||||
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: null } });
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(body.sessions)) return null;
|
||||
const { sessions, changes } = await sessionsUtil.syncEventSessions(tx, eventId, body.sessions);
|
||||
if (body.lateJoinUntilSessionIndex !== undefined) {
|
||||
const idx = parseInt(body.lateJoinUntilSessionIndex, 10);
|
||||
const ordered = [...sessions].sort((a, b) => a.order - b.order);
|
||||
const until = Number.isInteger(idx) && ordered[idx] && !ordered[idx].cancelledAt ? ordered[idx].id : null;
|
||||
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: until } });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
|
||||
function toAbsoluteUrl(req, url) {
|
||||
@@ -81,7 +160,14 @@ const createEvent = async (req, res) => {
|
||||
contactPhone: contactPhone || null,
|
||||
contactEmail: contactEmail || null,
|
||||
location: location || null,
|
||||
...scheduleData(req.body),
|
||||
};
|
||||
if (data.scheduleType === 'sessions') {
|
||||
// Check the sessions before anything is saved; the dates come from them.
|
||||
const bounds = sessionsUtil.scheduleBounds(sessionsUtil.normalizeSessionsInput(req.body.sessions).filter(s => !s.cancelled));
|
||||
data.startDate = bounds.startDate;
|
||||
data.endDate = bounds.endDate;
|
||||
}
|
||||
|
||||
const logEventCreate = (createdEvent) => logAdminAction({
|
||||
actorId: req.user?.id,
|
||||
@@ -96,6 +182,7 @@ const createEvent = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
if (data.scheduleType === 'sessions') await prisma.$transaction(tx => saveSchedule(tx, event.id, req.body));
|
||||
|
||||
// Automatically create a main ticket (event option) with the event price — contact-only
|
||||
// events have no bookable options, so there's nothing to auto-create for them.
|
||||
@@ -151,7 +238,7 @@ const createEvent = async (req, res) => {
|
||||
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
|
||||
}
|
||||
|
||||
return res.status(201).json(event);
|
||||
return res.status(201).json(withSchedule(await prisma.event.findUnique({ where: { id: event.id }, include: { sessions: SESSIONS_INCLUDE } })));
|
||||
} catch (err) {
|
||||
// Gracefully handle environments where migration isn't applied yet
|
||||
const msg = String(err?.message || '');
|
||||
@@ -186,10 +273,49 @@ const createEvent = async (req, res) => {
|
||||
throw err;
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Map of eventId → when its early bird ends, for events with at least one early-bird
|
||||
// tier still running: deadline not passed and (no stock limit, or places left, counted
|
||||
// across tranches the same way as utils/pricing.js). The date is the latest deadline
|
||||
// among the running tiers.
|
||||
async function earlyBirdEndsByEvent(events) {
|
||||
const now = new Date();
|
||||
const running = [];
|
||||
for (const ev of events) {
|
||||
for (const opt of ev.eventOptions || []) {
|
||||
for (const tier of opt.earlyBirdTiers || []) {
|
||||
if (new Date(tier.deadline) > now) running.push({ eventId: ev.id, tier });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const limitedIds = running.filter(r => r.tier.stockLimit > 0).map(r => r.tier.id);
|
||||
const sold = new Map();
|
||||
if (limitedIds.length > 0) {
|
||||
const totals = await prisma.registrationOptionTranche.groupBy({
|
||||
by: ['appliedTierId'],
|
||||
where: {
|
||||
appliedTierId: { in: limitedIds },
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
||||
},
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
for (const t of totals) sold.set(t.appliedTierId, t._sum?.quantity || 0);
|
||||
}
|
||||
|
||||
const ends = new Map();
|
||||
for (const { eventId, tier } of running) {
|
||||
if (tier.stockLimit > 0 && (sold.get(tier.id) || 0) >= tier.stockLimit) continue;
|
||||
const deadline = new Date(tier.deadline);
|
||||
const current = ends.get(eventId);
|
||||
if (!current || deadline > current) ends.set(eventId, deadline);
|
||||
}
|
||||
return ends;
|
||||
}
|
||||
|
||||
// @desc Get all events
|
||||
// @route GET /api/events
|
||||
// @access Public
|
||||
@@ -207,7 +333,7 @@ const getEvents = async (req, res) => {
|
||||
goLiveAt: { lte: new Date() },
|
||||
endDate: { gte: new Date() }
|
||||
},
|
||||
include: { eventOptions: eventOptionsInclude }
|
||||
include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE }
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback if goLiveAt/isHidden not available yet (pre-migration)
|
||||
@@ -220,20 +346,24 @@ const getEvents = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const earlyBirdEnds = await earlyBirdEndsByEvent(events).catch(() => new Map());
|
||||
|
||||
// Compute isSoldOut per event: true when every option that has a stock limit
|
||||
// is fully sold out. Events with no limited options are never sold out.
|
||||
const withStock = await Promise.all(events.map(async ev => {
|
||||
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
|
||||
try {
|
||||
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
|
||||
// Picked-session options have per-session limits, so they never make the event sold out.
|
||||
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0 && !sessionsUtil.isPickOption(ev, o));
|
||||
let isSoldOut = false;
|
||||
if (limitedOpts.length > 0) {
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id })));
|
||||
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
|
||||
}
|
||||
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut };
|
||||
return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut, earlyBirdEndsAt });
|
||||
} catch {
|
||||
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false };
|
||||
return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false, earlyBirdEndsAt });
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -251,11 +381,12 @@ const getAllEvents = async (req, res) => {
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const events = await prisma.event.findMany({
|
||||
include: {
|
||||
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true
|
||||
eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true,
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
}
|
||||
});
|
||||
|
||||
const mapped = events.map(ev => ({
|
||||
const mapped = events.map(ev => withSchedule({
|
||||
...ev,
|
||||
picture: toAbsoluteUrl(req, ev.picture),
|
||||
}));
|
||||
@@ -292,12 +423,13 @@ const getEventsAll = async (req, res) => {
|
||||
where,
|
||||
include: {
|
||||
eventOptions: optionInclude ? { include: optionInclude } : true,
|
||||
form: { include: { fields: true } }
|
||||
form: { include: { fields: true } },
|
||||
sessions: SESSIONS_INCLUDE,
|
||||
},
|
||||
orderBy: { startDate: 'asc' }
|
||||
});
|
||||
|
||||
const mapped = events.map(ev => ({
|
||||
const mapped = events.map(ev => withSchedule({
|
||||
...ev,
|
||||
picture: toAbsoluteUrl(req, ev.picture),
|
||||
}));
|
||||
@@ -320,7 +452,7 @@ const getEventById = async (req, res) => {
|
||||
const optionInclude = canIncludeTiers
|
||||
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
|
||||
: undefined;
|
||||
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true };
|
||||
const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE };
|
||||
|
||||
// Staff/supervisor/admin need the creator + notify-recipient list to populate the
|
||||
// event edit screen's Notifications step; public callers don't need this.
|
||||
@@ -431,6 +563,16 @@ const getEventById = async (req, res) => {
|
||||
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
|
||||
// Taken = non-cancelled registrations + places held for live waitlist offers
|
||||
const { takenCount } = require('../utils/stock');
|
||||
// Picked-session options: the stock limit is places per session.
|
||||
if (sessionsUtil.isPickOption(event, opt)) {
|
||||
const sessionAvailability = {};
|
||||
if (opt.stockLimit > 0) {
|
||||
for (const s of sessionsUtil.activeSessions(event.sessions)) {
|
||||
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
|
||||
}
|
||||
}
|
||||
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
|
||||
}
|
||||
const soldCount = await takenCount({ eventOptionId: opt.id });
|
||||
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
|
||||
|
||||
@@ -448,7 +590,9 @@ const getEventById = async (req, res) => {
|
||||
// Non-fatal: fall back to options without stock info
|
||||
}
|
||||
|
||||
const mapped = {
|
||||
if (!event.sessions) event.sessions = await prisma.eventSession.findMany({ where: { eventId }, ...SESSIONS_INCLUDE }).catch(() => []);
|
||||
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
|
||||
const mapped = withSchedule({
|
||||
...event,
|
||||
eventOptions: eventOptionsWithStock,
|
||||
picture: toAbsoluteUrl(req, event.picture),
|
||||
@@ -457,7 +601,8 @@ const getEventById = async (req, res) => {
|
||||
url: toAbsoluteUrl(req, att.url)
|
||||
})),
|
||||
form,
|
||||
};
|
||||
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
|
||||
}, bookings);
|
||||
res.json(mapped);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
@@ -472,7 +617,7 @@ const getEventIcs = async (req, res) => {
|
||||
const eventId = req.params.id;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true },
|
||||
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, sessions: { orderBy: { startsAt: 'asc' } } },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
@@ -582,10 +727,36 @@ const updateEvent = async (req, res) => {
|
||||
location: location !== undefined ? (location || null) : event.location,
|
||||
updatedAt: new Date(),
|
||||
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
|
||||
...scheduleData(req.body, event),
|
||||
};
|
||||
// A multi-session event's dates come from its sessions (set by saveSchedule).
|
||||
if ((data.scheduleType ?? event.scheduleType) === 'sessions') {
|
||||
data.startDate = event.startDate;
|
||||
data.endDate = event.endDate;
|
||||
if (Array.isArray(req.body.sessions)) sessionsUtil.normalizeSessionsInput(req.body.sessions);
|
||||
else if (event.scheduleType !== 'sessions') throw sessionsUtil.sessionError('Add the sessions for a multi-session event');
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
||||
// Event fields and sessions save together: a refused schedule change saves nothing.
|
||||
const scheduleBody = data.scheduleType === 'single' && event.scheduleType === 'sessions' ? { scheduleType: 'single' } : req.body;
|
||||
let updatedEvent;
|
||||
let sessionChanges;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
updatedEvent = await tx.event.update({ where: { id: req.params.id }, data });
|
||||
sessionChanges = updatedEvent.scheduleType === 'sessions' || scheduleBody.scheduleType === 'single'
|
||||
? await saveSchedule(tx, updatedEvent.id, scheduleBody)
|
||||
: null;
|
||||
});
|
||||
sessionsUtil.forgetSchedule(updatedEvent.id);
|
||||
if (sessionChanges && Object.values(sessionChanges).some(n => n > 0)) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'event_sessions_changed',
|
||||
targetType: 'Event', targetId: updatedEvent.id, metadata: sessionChanges, ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
updatedEvent = withSchedule(await prisma.event.findUnique({ where: { id: updatedEvent.id }, include: { sessions: SESSIONS_INCLUDE } }));
|
||||
updatedEvent.sessionChanges = sessionChanges;
|
||||
|
||||
|
||||
// Upsert form if provided
|
||||
@@ -767,7 +938,7 @@ const createEventOption = async (req, res) => {
|
||||
|
||||
await assertEventOpen(req.params.id, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit, staffPrice } = req.body;
|
||||
const { name, price, isMainTicket, stockLimit, staffPrice, sessionMode } = req.body;
|
||||
|
||||
const eventOption = await prisma.eventOption.create({
|
||||
data: {
|
||||
@@ -778,8 +949,10 @@ const createEventOption = async (req, res) => {
|
||||
isMainTicket: isMainTicket || false,
|
||||
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(staffPrice),
|
||||
sessionMode: sessionMode === 'pick' ? 'pick' : 'all',
|
||||
}
|
||||
});
|
||||
sessionsUtil.forgetSchedule(req.params.id);
|
||||
|
||||
res.status(201).json(eventOption);
|
||||
} catch (error) {
|
||||
@@ -804,7 +977,7 @@ const updateEventOption = async (req, res) => {
|
||||
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice } = req.body;
|
||||
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice, sessionMode } = req.body;
|
||||
|
||||
const updatedEventOption = await prisma.eventOption.update({
|
||||
where: { id: req.params.id },
|
||||
@@ -814,6 +987,7 @@ const updateEventOption = async (req, res) => {
|
||||
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
|
||||
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
|
||||
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
|
||||
sessionMode: sessionMode !== undefined ? (sessionMode === 'pick' ? 'pick' : 'all') : eventOption.sessionMode,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const jobs = require('../jobs');
|
||||
|
||||
// @desc Background jobs: schedule, next run and the last 10 runs of each
|
||||
// @route GET /api/admin/jobs
|
||||
// @access Private/Admin
|
||||
const listJobs = async (req, res) => {
|
||||
try {
|
||||
res.json(await jobs.jobStatus(prisma));
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Run a background job now
|
||||
// @route POST /api/admin/jobs/:name/run
|
||||
// @access Private/Admin
|
||||
const runJobNow = async (req, res) => {
|
||||
try {
|
||||
const result = await jobs.runNow(req.params.name);
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'job_run_manual',
|
||||
targetType: 'Job', targetId: req.params.name, metadata: { queued: result.queued }, ip: getClientIp(req),
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listJobs, runJobNow };
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ const TICKET_DELIVERY = ['payer', 'each'];
|
||||
*/
|
||||
function placesAfter(existingLines, requested) {
|
||||
const out = {};
|
||||
for (const l of [...(existingLines || []), ...(requested || [])]) {
|
||||
// Picked-session lines take no attendee names.
|
||||
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
|
||||
const k = lineKey(l.eventOptionId, l.variantId);
|
||||
out[k] = (out[k] || 0) + Number(l.quantity || 0);
|
||||
}
|
||||
@@ -51,6 +52,8 @@ 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');
|
||||
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption } = require('../utils/sessions');
|
||||
|
||||
/**
|
||||
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
||||
@@ -90,18 +93,18 @@ async function priceRequestedOption(eventOption, variantId, qty, buyer) {
|
||||
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
||||
* Returns { available: boolean, remaining: number|null }
|
||||
*/
|
||||
async function checkOptionStock(option, requestedQty, excludeEntryId = null) {
|
||||
async function checkOptionStock(option, requestedQty, excludeEntryId = null, sessionId = null) {
|
||||
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id }, { excludeEntryId });
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check stock availability for a specific variant (same rules as checkOptionStock).
|
||||
*/
|
||||
async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
|
||||
async function checkVariantStock(variant, requestedQty, excludeEntryId = null, sessionId = null) {
|
||||
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id }, { excludeEntryId });
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
@@ -120,7 +123,7 @@ const createRegistration = async (req, res) => {
|
||||
: true;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: optionInclude ? { include: optionInclude } : true }
|
||||
include: { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: true }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
@@ -206,11 +209,10 @@ const createRegistration = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce registration cutoff for public registrations
|
||||
// Enforce registration cutoff for public registrations (multi-day events close when
|
||||
// the first session starts, or later with late joining — see utils/sessions.js).
|
||||
const now = new Date();
|
||||
const end = new Date(event.endDate);
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
if ((deadline && now >= deadline) || now >= end) {
|
||||
if (now >= registrationClosesAt(event, event.sessions)) {
|
||||
res.status(400);
|
||||
throw new Error('Registration is closed for this event');
|
||||
}
|
||||
@@ -221,11 +223,20 @@ const createRegistration = async (req, res) => {
|
||||
throw new Error('At least one option must be selected');
|
||||
}
|
||||
|
||||
// "Pick sessions" options become one line per chosen session; whole-course tickets
|
||||
// on such an event still close when the whole course does.
|
||||
const lines = expandPickLines(event, options, { now });
|
||||
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId) && now >= wholeCourseClosesAt(event, event.sessions)) {
|
||||
res.status(400);
|
||||
throw new Error('Whole-course tickets are no longer on sale — choose the sessions you want instead.');
|
||||
}
|
||||
await assertSessionCapacity(event, lines);
|
||||
|
||||
// Validate each option and resolve prices / check stock.
|
||||
// Options are independent of each other, so resolve them concurrently. Errors are marked
|
||||
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
|
||||
// across concurrent iterations) — the outer catch always responds 400 regardless.
|
||||
const resolvedOptions = await Promise.all(options.map(async (option) => {
|
||||
const resolvedOptions = await Promise.all(lines.map(async (option) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption) {
|
||||
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
|
||||
@@ -238,7 +249,7 @@ const createRegistration = async (req, res) => {
|
||||
|
||||
// Check overall option stock
|
||||
try {
|
||||
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id);
|
||||
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id, option.sessionId);
|
||||
if (!stockCheck.available) {
|
||||
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
@@ -258,7 +269,7 @@ const createRegistration = async (req, res) => {
|
||||
throw new Error(`Variant not found for option "${eventOption.name}"`);
|
||||
}
|
||||
try {
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id);
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId);
|
||||
if (!vStock.available) {
|
||||
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
@@ -319,9 +330,9 @@ const createRegistration = async (req, res) => {
|
||||
// tickets bought at different early-bird prices into a single (wrong) price. Each
|
||||
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
|
||||
for (const opt of resolvedOptions) {
|
||||
// Match on eventOptionId + variantId for correct row
|
||||
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
|
||||
);
|
||||
if (existing) {
|
||||
await prisma.$transaction([
|
||||
@@ -347,6 +358,7 @@ const createRegistration = async (req, res) => {
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -394,6 +406,7 @@ const createRegistration = async (req, res) => {
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -429,9 +442,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 +454,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 +462,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,
|
||||
}
|
||||
});
|
||||
@@ -522,6 +537,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: {
|
||||
@@ -601,6 +618,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
|
||||
@@ -651,6 +670,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,
|
||||
};
|
||||
|
||||
@@ -894,6 +915,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' }
|
||||
@@ -930,7 +953,7 @@ const createManualRegistration = async (req, res) => {
|
||||
//Check event
|
||||
const event = await prisma.event.findUnique({
|
||||
where: {id: eventId},
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } }, sessions: true },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
@@ -951,9 +974,13 @@ const createManualRegistration = async (req, res) => {
|
||||
throw new Error('At least one option must be selected');
|
||||
}
|
||||
|
||||
// Picked sessions become one line each; staff can still book a session until it ends.
|
||||
const lines = expandPickLines(event, options, { staff: true });
|
||||
await assertSessionCapacity(event, lines);
|
||||
|
||||
// Validate each option up front, before any account is created or changed —
|
||||
// including stock, which staff registrations previously skipped.
|
||||
for (const option of options) {
|
||||
for (const option of lines) {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption) {
|
||||
res.status(400);
|
||||
@@ -964,8 +991,8 @@ const createManualRegistration = async (req, res) => {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
const variant = option.variantId ? (eventOption.variants || []).find(v => v.id === option.variantId) : null;
|
||||
const oStock = await checkOptionStock(eventOption, option.quantity);
|
||||
const vStock = variant ? await checkVariantStock(variant, option.quantity) : { available: true };
|
||||
const oStock = await checkOptionStock(eventOption, option.quantity, null, option.sessionId);
|
||||
const vStock = variant ? await checkVariantStock(variant, option.quantity, null, option.sessionId) : { available: true };
|
||||
if (!oStock.available || !vStock.available) {
|
||||
res.status(400);
|
||||
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
|
||||
@@ -975,7 +1002,7 @@ const createManualRegistration = async (req, res) => {
|
||||
|
||||
// Pricing (appliedTierId + priceSnapshot) depends on who the attendee is — Staff and
|
||||
// above get the staff price — so it runs once the account is resolved below.
|
||||
const priceManualOptions = (attendee) => Promise.all(options.map(async (option) => {
|
||||
const priceManualOptions = (attendee) => Promise.all(lines.map(async (option) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
const variantId = option.variantId || null;
|
||||
const priced = await priceRequestedOption(eventOption, variantId, option.quantity, attendee);
|
||||
@@ -1147,7 +1174,7 @@ const createManualRegistration = async (req, res) => {
|
||||
// tickets bought at different early-bird prices would blend into a single wrong price.
|
||||
await Promise.all(resolvedManualOptions.map(opt => {
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
|
||||
);
|
||||
if (existing) {
|
||||
return prisma.$transaction([
|
||||
@@ -1177,6 +1204,7 @@ const createManualRegistration = async (req, res) => {
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -1225,6 +1253,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;
|
||||
@@ -1243,6 +1273,7 @@ const createManualRegistration = async (req, res) => {
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -1262,6 +1293,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 });
|
||||
@@ -1269,6 +1301,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: {
|
||||
@@ -1276,6 +1310,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1447,6 +1483,7 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
}
|
||||
|
||||
const validOptionIds = new Set((registration.event?.eventOptions || []).map(eo => eo.id));
|
||||
const pickOptionIds = new Set((registration.event?.eventOptions || []).filter(eo => isPickOption(registration.event, eo)).map(eo => eo.id));
|
||||
for (const opt of options) {
|
||||
if (!opt || !opt.eventOptionId || !Number.isInteger(opt.quantity) || opt.quantity < 1) {
|
||||
res.status(400);
|
||||
@@ -1456,8 +1493,17 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
res.status(400);
|
||||
throw new Error('One or more options do not belong to this event');
|
||||
}
|
||||
if (pickOptionIds.has(opt.eventOptionId)) {
|
||||
res.status(400);
|
||||
throw new Error("Sessions are booked by registering for them — they can't be changed here.");
|
||||
}
|
||||
}
|
||||
|
||||
// Picked-session lines aren't edited here: leave them exactly as they are (they still
|
||||
// count towards the total due).
|
||||
const pickedLines = registration.registrationOptions.filter(ro => ro.sessionId);
|
||||
registration.registrationOptions = registration.registrationOptions.filter(ro => !ro.sessionId);
|
||||
|
||||
// Compute totalPaid
|
||||
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||
|
||||
@@ -1558,6 +1604,7 @@ const updateRegistrationOptions = async (req, res) => {
|
||||
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId, existingRows, existingQty, delta, removalPlan });
|
||||
}
|
||||
|
||||
newTotalDue += rowsTotal(pickedLines);
|
||||
if (newTotalDue < totalPaid) {
|
||||
res.status(400);
|
||||
throw new Error('Cannot reduce items below the amount already paid');
|
||||
@@ -1717,6 +1764,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,
|
||||
}
|
||||
});
|
||||
@@ -2123,6 +2172,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)}.` });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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');
|
||||
@@ -87,7 +88,7 @@ const createSponsorCheckout = async (req, res) => {
|
||||
lineItems: [{ displayName: label, quantity: 1, pricingDetails: { price: amountInCents } }],
|
||||
};
|
||||
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, {
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
|
||||
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) {
|
||||
|
||||
@@ -92,6 +92,7 @@ const getUserTickets = async (req, res) => {
|
||||
include: {
|
||||
eventOption: true,
|
||||
variant: true,
|
||||
session: true,
|
||||
registration: true
|
||||
}
|
||||
},
|
||||
@@ -102,7 +103,7 @@ const getUserTickets = async (req, res) => {
|
||||
},
|
||||
},
|
||||
user: { select: { id: true, name: true } },
|
||||
event: true,
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
|
||||
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
|
||||
usages: {
|
||||
include: {
|
||||
@@ -262,12 +263,14 @@ const getScanPreview = async (req, res) => {
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
ro.id AS "registrationOptionId",
|
||||
ro."sessionId" AS "pinnedSessionId",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
ov.id AS "variantId",
|
||||
ov.name AS "variantName",
|
||||
e.id AS "evId",
|
||||
e.title AS "eventTitle",
|
||||
e."scheduleType" AS "scheduleType",
|
||||
u.id AS "userId",
|
||||
u.name AS "userName",
|
||||
a.name AS "attendeeName",
|
||||
@@ -275,7 +278,8 @@ const getScanPreview = async (req, res) => {
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'quantityRedeemed', tu."quantityRedeemed",
|
||||
'scannedAt', tu."scannedAt"
|
||||
'scannedAt', tu."scannedAt",
|
||||
'sessionId', tu."sessionId"
|
||||
)
|
||||
) FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
@@ -303,12 +307,33 @@ const getScanPreview = async (req, res) => {
|
||||
const r = rows[0];
|
||||
const { consentStatusForTickets } = require('../utils/consent');
|
||||
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
|
||||
const usages = r.usages || [];
|
||||
// Multi-day events: check-in is per session, so what's left is counted for the
|
||||
// session this scan is for (the one picked, or today's).
|
||||
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
let session = null;
|
||||
let sessionError = null;
|
||||
if (r.scheduleType === 'sessions') {
|
||||
const sessionsUtil = require('../utils/sessions');
|
||||
const schedule = await sessionsUtil.eventSchedule(r.eventId);
|
||||
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null);
|
||||
sessionError = found.error;
|
||||
if (found.session) {
|
||||
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
|
||||
remaining = Number(r.quantity) - sessionsUtil.redeemedForSession(usages, found.session.id);
|
||||
} else {
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
consentStatus,
|
||||
remaining,
|
||||
session,
|
||||
sessionError,
|
||||
registrationOption: {
|
||||
id: r.registrationOptionId,
|
||||
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
|
||||
@@ -317,7 +342,7 @@ const getScanPreview = async (req, res) => {
|
||||
event: { id: r.evId, title: r.eventTitle },
|
||||
user: { id: r.userId, name: r.userName },
|
||||
attendee: r.attendeeName ? { name: r.attendeeName } : null,
|
||||
usages: r.usages || []
|
||||
usages
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -337,6 +362,8 @@ const scanTicket = async (req, res) => {
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
e.title AS "eventTitle",
|
||||
e."scheduleType" AS "scheduleType",
|
||||
ro."sessionId" AS "pinnedSessionId",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
eo."isMainTicket" AS "isMainTicket",
|
||||
@@ -345,7 +372,8 @@ const scanTicket = async (req, res) => {
|
||||
json_build_object(
|
||||
'id', tu.id,
|
||||
'quantityRedeemed', tu."quantityRedeemed",
|
||||
'scannedAt', tu."scannedAt"
|
||||
'scannedAt', tu."scannedAt",
|
||||
'sessionId', tu."sessionId"
|
||||
)
|
||||
) FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
@@ -356,7 +384,7 @@ const scanTicket = async (req, res) => {
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, e.id, eo.id
|
||||
GROUP BY t.id, e.id, eo.id, ro.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
@@ -405,11 +433,35 @@ const scanTicket = async (req, res) => {
|
||||
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' });
|
||||
}
|
||||
|
||||
// Multi-day events: the check-in is for one session (the one picked, or today's), and
|
||||
// what's left is counted per session.
|
||||
let session = null;
|
||||
let sessionsUtil = null;
|
||||
let schedule = null;
|
||||
if (r.scheduleType === 'sessions') {
|
||||
sessionsUtil = require('../utils/sessions');
|
||||
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
|
||||
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null);
|
||||
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
|
||||
session = found.session;
|
||||
}
|
||||
|
||||
// Compute how many have already been redeemed
|
||||
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const totalRedeemed = session
|
||||
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
|
||||
: (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (session) {
|
||||
const last = (ticket.usages || []).filter(u => u.sessionId === session.id).sort((a, b) => new Date(b.scannedAt) - new Date(a.scannedAt))[0];
|
||||
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
|
||||
return res.status(403).json({
|
||||
message: `Already checked in for ${sessionsUtil.sessionLabel(session, schedule.sessions)}${at ? ` at ${at}` : ''}`,
|
||||
ticket,
|
||||
remaining: 0
|
||||
});
|
||||
}
|
||||
return res.status(403).json({
|
||||
message: 'Ticket has already been fully used',
|
||||
ticket,
|
||||
@@ -452,11 +504,13 @@ const scanTicket = async (req, res) => {
|
||||
ticketId: ticket.id,
|
||||
scannedById: req.user.id,
|
||||
quantityRedeemed: qtyToRedeem,
|
||||
sessionId: session ? session.id : null,
|
||||
}
|
||||
}),
|
||||
// A multi-session ticket is never "used up" — it's checked in again next session.
|
||||
prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { isUsed: fullyUsed, updatedAt: new Date() }
|
||||
data: { isUsed: session ? false : fullyUsed, updatedAt: new Date() }
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -468,8 +522,10 @@ const scanTicket = async (req, res) => {
|
||||
.catch(e => console.error('Failed to send check-in notification:', e));
|
||||
}
|
||||
|
||||
const sessionPart = session ? ` for ${sessionsUtil.sessionLabel(session, schedule.sessions)}` : '';
|
||||
res.json({
|
||||
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
||||
message: `Ticket scanned successfully${sessionPart} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
||||
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
|
||||
ticketUsage,
|
||||
ticket,
|
||||
qtyRedeemed: qtyToRedeem,
|
||||
@@ -491,10 +547,10 @@ const getTicketsByEvent = async (req, res) => {
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: { eventId: req.params.eventId },
|
||||
include: {
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
|
||||
registrationOption: { include: { eventOption: true, variant: true, session: 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 } },
|
||||
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true, scheduleType: true, sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } } } },
|
||||
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -845,6 +901,67 @@ function allocationLabel(attendee) {
|
||||
.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 = require('../utils/sessions').ticketDateText(ticket.event, 'short', ticket.registrationOption.session)
|
||||
|| (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;
|
||||
@@ -865,8 +982,8 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
}
|
||||
|
||||
const ticketInclude = {
|
||||
event: true,
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
|
||||
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { ticketDelivery: true } } } },
|
||||
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
|
||||
user: { select: { name: true } },
|
||||
usages: true,
|
||||
@@ -951,32 +1068,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 });
|
||||
const groupLabel = allocationLabel(ticket.attendee);
|
||||
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}${groupLabel ? ` · ${groupLabel}` : ''}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
||||
const eventDate = new Date(ticket.event.startDate);
|
||||
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
|
||||
doc.text(`Purchased by: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
|
||||
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();
|
||||
}
|
||||
@@ -984,9 +1091,10 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
|
||||
|
||||
const eventTitle = tickets[0].event.title;
|
||||
const eventDate = tickets[0].event.startDate
|
||||
const pinnedSessions = tickets.map(t => t.registrationOption?.session).filter(Boolean);
|
||||
const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long', pinnedSessions.length === tickets.length && pinnedSessions.length === 1 ? pinnedSessions[0] : null, pinnedSessions.length === tickets.length ? pinnedSessions : null) || (tickets[0].event.startDate
|
||||
? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
: '');
|
||||
const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
const sentChannels = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -357,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 = [];
|
||||
@@ -391,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;
|
||||
@@ -401,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 };
|
||||
+59
-199
@@ -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;
|
||||
@@ -149,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);
|
||||
@@ -180,14 +178,17 @@ 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/attendance', require('./routes/attendanceRoutes'));
|
||||
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');
|
||||
@@ -546,7 +547,7 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[
|
||||
{ status:201, desc:'Created', body:{ id:'ev-uuid-new', title:'Camp 2025', createdAt:'2024-11-01T09:00:00.000Z' }},
|
||||
]},
|
||||
{ method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event',
|
||||
{ method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event. Multi-day events send scheduleType:"sessions" with the full sessions list (rows left out are deleted; ones with check-ins must be cancelled instead), plus lateJoinEnabled, lateJoinUntilSessionIndex, latePricing, lateFixedPrice, checkInWindow, sessionReminders, certificatesEnabled and certificateConfig. The response includes sessionChanges.',
|
||||
pathParams:{ ':id':'Event UUID' },
|
||||
request:{ body:{ title:'Camp 2025 Updated', price:500 }},
|
||||
responses:[{ status:200, desc:'Updated', body:{ id:'ev-uuid-...', title:'Camp 2025 Updated', price:500 }}]},
|
||||
@@ -660,6 +661,8 @@ 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:'GET', path:'/api/admin/jobs', auth:'admin', desc:'Background jobs: queue mode, timezone, and each job schedule, next run and last 10 runs' },
|
||||
{ method:'POST', path:'/api/admin/jobs/:name/run', auth:'admin', desc:'Run a background job now (queued for a worker); audit-logged' },
|
||||
{ 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' },
|
||||
@@ -739,6 +742,20 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} },
|
||||
{ method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' },
|
||||
]},
|
||||
{ title: 'Attendance & certificates', base: '/api/attendance', endpoints: [
|
||||
{ method:'GET', path:'/api/attendance/:eventId', auth:'staff+', desc:'Attendance register: sessions (columns), one row per main ticket with check-ins per session, completion and certificate',
|
||||
responses:[{ status:200, desc:'Register', body:{ event:{ id:'ev-uuid-...', scheduleType:'sessions' }, columns:[{ id:'ses-uuid-...', label:'Week 1', startsAt:'2026-11-04T16:30:00.000Z', cancelled:false }], rows:[{ ticketId:'t-uuid-...', name:'Thandi', checkIns:{ 'ses-uuid-...':1 }, attended:1, total:5, completed:false, certificate:null }], totals:{ 'ses-uuid-...':12 } }}]},
|
||||
{ method:'GET', path:'/api/attendance/:eventId/export?format=xlsx|pdf', auth:'staff+', desc:'Download the register as Excel or PDF' },
|
||||
{ method:'POST', path:'/api/attendance/:eventId/mark', auth:'supervisor+', desc:'Mark a ticket present/absent for a session (single-date events use sessionId "event")', request:{ body:{ ticketId:'t-uuid-...', sessionId:'ses-uuid-...', present:true }} },
|
||||
{ method:'POST', path:'/api/attendance/:eventId/certificates', auth:'supervisor+', desc:'Issue certificates now to everyone eligible, or to one ticket (even if not eligible)', request:{ body:{ ticketId:'t-uuid-... (optional)' }} },
|
||||
{ method:'POST', path:'/api/attendance/:eventId/certificates/send', auth:'supervisor+', desc:'Email/WhatsApp unsent certificates, or one again', request:{ body:{ certificateId:'(optional)' }} },
|
||||
{ method:'GET', path:'/api/attendance/:eventId/certificates.pdf', auth:'supervisor+', desc:'All of an event\'s certificates in one PDF' },
|
||||
{ method:'POST', path:'/api/attendance/certificates/preview', auth:'supervisor+', desc:'Sample certificate PDF with unsaved settings', request:{ body:{ title:'Alpha Course', config:{ template:'classic' } }} },
|
||||
{ method:'POST', path:'/api/attendance/certificates/:id/revoke', auth:'supervisor+', desc:'Revoke a certificate (never re-issued automatically)' },
|
||||
{ method:'GET', path:'/api/attendance/certificates/mine', auth:'user+', desc:'My certificates' },
|
||||
{ method:'GET', path:'/api/attendance/certificates/:id/pdf', auth:'user+', desc:'Download a certificate (its person, the booker, or a supervisor)' },
|
||||
{ method:'GET', path:'/api/attendance/certificates/verify/:code', auth:'public', desc:'Check a certificate code — name, event and dates only', responses:[{ status:200, desc:'Result', body:{ valid:true, name:'Thandi Mokoena', event:'Alpha Course', completedOn:'2026-12-02T18:30:00.000Z' }}] },
|
||||
]},
|
||||
{ title: 'Parental consent', base: '/api/consent', endpoints: [
|
||||
{ method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)',
|
||||
responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]},
|
||||
@@ -755,7 +772,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',
|
||||
@@ -767,6 +784,16 @@ 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' },
|
||||
@@ -1045,6 +1072,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' }}]},
|
||||
@@ -1405,199 +1447,9 @@ app.listen(PORT, () => {
|
||||
console.warn('[attachments sync] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Schedule daily summaries at 07:00 local time (configurable)
|
||||
try {
|
||||
const enabled = String(process.env.DAILY_SUMMARY_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { sendDailyEventSummaries } = require('./utils/notifications');
|
||||
function msUntilNext(hour, minute) {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
if (next <= now) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
return next.getTime() - now.getTime();
|
||||
}
|
||||
async function scheduleNext() {
|
||||
const delay = msUntilNext(7, 0);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
console.log('[daily summaries] Running daily event summaries...');
|
||||
await sendDailyEventSummaries(new Date());
|
||||
console.log('[daily summaries] Completed sending daily event summaries.');
|
||||
} catch (e) {
|
||||
console.error('[daily summaries] Failed:', e?.message || e);
|
||||
} finally {
|
||||
// Schedule next run
|
||||
scheduleNext();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
scheduleNext();
|
||||
console.log('[daily summaries] Scheduler initialized (07:00 local time). Set DAILY_SUMMARY_ENABLED=false to disable.');
|
||||
} else {
|
||||
console.log('[daily summaries] Scheduler disabled by env DAILY_SUMMARY_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[daily summaries] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Waitlist job — expire unclaimed offers and offer any free places to the next people.
|
||||
try {
|
||||
const enabled = String(process.env.WAITLIST_JOB_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runWaitlistJob } = require('./utils/waitlist');
|
||||
const everyMs = parseInt(process.env.WAITLIST_JOB_INTERVAL_MS || String(10 * 60 * 1000), 10);
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await runWaitlistJob();
|
||||
if (r.expired || r.offers) console.log(`[waitlist] ${r.expired} event(s) with expired offers, ${r.offers} new offer(s).`);
|
||||
} catch (e) {
|
||||
console.warn('[waitlist] Job failed:', e?.message || e);
|
||||
}
|
||||
}, everyMs);
|
||||
console.log(`[waitlist] Job every ${Math.round(everyMs / 60000)} min.`);
|
||||
} else {
|
||||
console.log('[waitlist] Job disabled by env WAITLIST_JOB_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[waitlist] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Funnel analytics — once a day, roll raw rows older than 90 days into daily totals.
|
||||
try {
|
||||
const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { rollupOldEvents } = require('./utils/analytics');
|
||||
const run = async () => {
|
||||
try {
|
||||
const n = await rollupOldEvents();
|
||||
if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`);
|
||||
} catch (e) {
|
||||
console.warn('[analytics] Rollup failed:', e?.message || e);
|
||||
}
|
||||
};
|
||||
setTimeout(run, 5 * 60 * 1000);
|
||||
setInterval(run, 24 * 60 * 60 * 1000);
|
||||
console.log('[analytics] Rollup daily.');
|
||||
} else {
|
||||
console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[analytics] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Feedback job — send post-event feedback forms at their send time, and the one
|
||||
// reminder 3 days later.
|
||||
try {
|
||||
const enabled = String(process.env.FEEDBACK_JOB_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runFeedbackJob } = require('./utils/feedback');
|
||||
const everyMs = parseInt(process.env.FEEDBACK_JOB_INTERVAL_MS || String(15 * 60 * 1000), 10);
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await runFeedbackJob();
|
||||
if (r.sent || r.reminded) console.log(`[feedback] ${r.sent} invite(s), ${r.reminded} reminder(s) sent.`);
|
||||
} catch (e) {
|
||||
console.warn('[feedback] Job failed:', e?.message || e);
|
||||
}
|
||||
}, everyMs);
|
||||
console.log(`[feedback] Job every ${Math.round(everyMs / 60000)} min.`);
|
||||
} else {
|
||||
console.log('[feedback] Job disabled by env FEEDBACK_JOB_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[feedback] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily parental-consent job — reminders for unanswered requests, and purging
|
||||
// medical/emergency details a set number of days after each event.
|
||||
try {
|
||||
const enabled = String(process.env.CONSENT_JOBS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { sendConsentReminders, purgeConsentMedicalData } = require('./utils/consent');
|
||||
const runConsentJobs = async () => {
|
||||
try {
|
||||
const reminded = await sendConsentReminders(new Date());
|
||||
const purged = await purgeConsentMedicalData(new Date());
|
||||
if (reminded || purged) console.log(`[consent] Sent ${reminded} reminder(s), purged medical details on ${purged} request(s).`);
|
||||
} catch (e) {
|
||||
console.warn('[consent] Daily job failed:', e?.message || e);
|
||||
}
|
||||
};
|
||||
const scheduleConsent = () => {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(9, 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
setTimeout(async () => { await runConsentJobs(); scheduleConsent(); }, next.getTime() - now.getTime());
|
||||
};
|
||||
scheduleConsent();
|
||||
console.log('[consent] Scheduler initialized (09:00 local time).');
|
||||
} else {
|
||||
console.log('[consent] Scheduler disabled by env CONSENT_JOBS_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[consent] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily temp-file cleanup — removes files older than 7 days from /temp
|
||||
try {
|
||||
const { cleanupTempFiles } = require('./utils/cleanupTemp');
|
||||
function scheduleTempCleanup() {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(3, 0, 0, 0); // Run at 03:00 local time (low-traffic window)
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const result = cleanupTempFiles();
|
||||
if (result.deleted > 0 || result.errors > 0) {
|
||||
console.log(`[temp cleanup] Deleted ${result.deleted} file(s), ${result.errors} error(s).`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[temp cleanup] Failed:', e?.message || e);
|
||||
} finally {
|
||||
scheduleTempCleanup();
|
||||
}
|
||||
}, next.getTime() - now.getTime());
|
||||
}
|
||||
scheduleTempCleanup();
|
||||
console.log('[temp cleanup] Scheduler initialized (03:00 local time, files older than 7 days).');
|
||||
} catch (e) {
|
||||
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Nightly database backup at 02:00 local time (before the 03:00 temp cleanup)
|
||||
try {
|
||||
const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runBackup } = require('./utils/backupUtils');
|
||||
function scheduleNightlyBackup() {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(2, 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const result = await runBackup();
|
||||
console.log(`[backup] Completed: ${result.filename}`);
|
||||
} catch (e) {
|
||||
console.error('[backup] Failed:', e?.message || e);
|
||||
} finally {
|
||||
scheduleNightlyBackup();
|
||||
}
|
||||
}, next.getTime() - now.getTime());
|
||||
}
|
||||
scheduleNightlyBackup();
|
||||
console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.');
|
||||
} else {
|
||||
console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[backup] Not scheduled:', e?.message || e);
|
||||
}
|
||||
// Timed background jobs (daily summary, waitlist, feedback, sessions, consent, analytics,
|
||||
// backup, temp cleanup) run on the pg-boss job queue — see src/jobs/index.js.
|
||||
require('./jobs').startJobs().catch(e => console.warn('[jobs] Not started:', e?.message || e));
|
||||
|
||||
// Scheduled emails worker (polling)
|
||||
try {
|
||||
@@ -1659,6 +1511,14 @@ app.listen(PORT, () => {
|
||||
// Log unhandled promise rejections without killing the server, since a single
|
||||
// missed .catch() on fire-and-forget notification code (email/WhatsApp sends)
|
||||
// would otherwise take the whole app down.
|
||||
// On restart/stop (PM2 sends SIGINT), let a running background job finish first.
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, async () => {
|
||||
try { await require('./jobs').stopJobs(); } catch { /* exiting anyway */ }
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (err) => {
|
||||
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
|
||||
console.error(err?.stack || err);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Background jobs — one registry, run by pg-boss (a job queue stored in Postgres).
|
||||
*
|
||||
* Why a queue instead of setInterval:
|
||||
* - Schedules live in the database, so a job runs once per schedule even when more than
|
||||
* one backend process is running (PM2 cluster, two servers on one database).
|
||||
* - Every run is recorded (state, start/end, result or error), shown on the admin
|
||||
* Background jobs page, which can also run a job straight away.
|
||||
* - A run that fails is retried, and a slow run never overlaps the next one.
|
||||
*
|
||||
* Each job can be switched off with its env flag (e.g. WAITLIST_JOB_ENABLED=false), and
|
||||
* its schedule changed with JOB_SCHEDULE_<NAME> (cron, e.g. JOB_SCHEDULE_WAITLIST="*\/5 * * * *").
|
||||
* JOBS_ENABLED=false turns all of them off (used by the end-to-end tests).
|
||||
* If the queue can't start (e.g. the database user can't create the pgboss schema), the
|
||||
* same jobs run on in-process timers instead, as before.
|
||||
*/
|
||||
|
||||
const cronParser = require('cron-parser');
|
||||
|
||||
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
|
||||
const SCHEMA = 'pgboss';
|
||||
|
||||
const JOBS = [
|
||||
{
|
||||
name: 'daily-summary', label: 'Daily event summary email', cron: '0 7 * * *', env: 'DAILY_SUMMARY_ENABLED',
|
||||
run: async () => { await require('../utils/notifications').sendDailyEventSummaries(new Date()); return { sent: true }; },
|
||||
},
|
||||
{
|
||||
name: 'waitlist', label: 'Waitlist: expire offers, offer freed places', cron: '*/10 * * * *', env: 'WAITLIST_JOB_ENABLED',
|
||||
run: () => require('../utils/waitlist').runWaitlistJob(),
|
||||
},
|
||||
{
|
||||
name: 'feedback', label: 'Post-event feedback forms and reminders', cron: '*/15 * * * *', env: 'FEEDBACK_JOB_ENABLED',
|
||||
run: () => require('../utils/feedback').runFeedbackJob(),
|
||||
},
|
||||
{
|
||||
name: 'sessions', label: 'Session reminders and certificates', cron: '*/5 * * * *', env: 'SESSION_JOBS_ENABLED',
|
||||
run: async () => {
|
||||
const reminders = await require('../utils/sessionReminders').runSessionReminderJob();
|
||||
const certificates = await require('../utils/certificates').runCertificateJob();
|
||||
return { reminders, certificates };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
|
||||
run: async () => {
|
||||
const { sendConsentReminders, purgeConsentMedicalData } = require('../utils/consent');
|
||||
return { reminded: await sendConsentReminders(new Date()), purged: await purgeConsentMedicalData(new Date()) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'analytics-rollup', label: 'Funnel analytics: roll up visits older than 90 days', cron: '15 4 * * *', env: 'ANALYTICS_ROLLUP_ENABLED',
|
||||
run: async () => ({ rolledUp: await require('../utils/analytics').rollupOldEvents() }),
|
||||
},
|
||||
{
|
||||
name: 'backup', label: 'Database backup', cron: '0 2 * * *', env: 'BACKUP_ENABLED',
|
||||
run: async () => ({ file: (await require('../utils/backupUtils').runBackup()).filename }),
|
||||
},
|
||||
{
|
||||
name: 'temp-cleanup', label: 'Delete temp files older than 7 days', cron: '0 3 * * *', env: null,
|
||||
run: async () => require('../utils/cleanupTemp').cleanupTempFiles(),
|
||||
},
|
||||
];
|
||||
|
||||
const flag = (name, def = true) => {
|
||||
const v = process.env[name];
|
||||
return v === undefined || v === '' ? def : String(v).toLowerCase() !== 'false';
|
||||
};
|
||||
|
||||
/** Whether a job is switched on (JOBS_ENABLED and its own flag). */
|
||||
function isEnabled(job) {
|
||||
return flag('JOBS_ENABLED') && (!job.env || flag(job.env));
|
||||
}
|
||||
|
||||
/** A job's cron schedule, with any JOB_SCHEDULE_<NAME> override (checked). */
|
||||
function cronFor(job) {
|
||||
const override = process.env[`JOB_SCHEDULE_${job.name.toUpperCase().replace(/-/g, '_')}`];
|
||||
if (override) {
|
||||
try {
|
||||
cronParser.parseExpression(override, { tz: TIMEZONE });
|
||||
return override;
|
||||
} catch {
|
||||
console.warn(`[jobs] Ignoring invalid schedule for ${job.name}: "${override}"`);
|
||||
}
|
||||
}
|
||||
return job.cron;
|
||||
}
|
||||
|
||||
/** When a cron schedule next fires after `from`. */
|
||||
function nextRun(cron, from = new Date()) {
|
||||
return cronParser.parseExpression(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
|
||||
}
|
||||
|
||||
function summarize(result) {
|
||||
try {
|
||||
const s = JSON.stringify(result ?? null);
|
||||
return s.length > 300 ? `${s.slice(0, 300)}…` : s;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a job's result says nothing happened (all zeros/false/empty). */
|
||||
function isNoop(value) {
|
||||
if (value === null || value === undefined || value === 0 || value === false || value === '') return true;
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
if (typeof value === 'object') return Object.values(value).every(isNoop);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runJob(job) {
|
||||
const started = Date.now();
|
||||
const result = await job.run();
|
||||
// Quiet when nothing happened, like the old timers.
|
||||
if (!isNoop(result)) console.log(`[jobs] ${job.name} done in ${Date.now() - started} ms: ${summarize(result)}`);
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
// ─── Runner ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let boss = null;
|
||||
let mode = 'off'; // 'queue' | 'timers' | 'off'
|
||||
const timers = new Map();
|
||||
// Timer mode keeps its own short history so the admin page still has something to show.
|
||||
const timerHistory = new Map();
|
||||
|
||||
function startTimers(jobs) {
|
||||
mode = 'timers';
|
||||
for (const job of jobs) {
|
||||
const cron = cronFor(job);
|
||||
const scheduleNext = () => {
|
||||
const delay = Math.max(1000, nextRun(cron).getTime() - Date.now());
|
||||
timers.set(job.name, setTimeout(async () => {
|
||||
const entry = { state: 'active', startedOn: new Date(), completedOn: null, output: null };
|
||||
try {
|
||||
entry.output = await runJob(job);
|
||||
entry.state = 'completed';
|
||||
} catch (e) {
|
||||
entry.state = 'failed';
|
||||
entry.output = { message: e?.message || String(e) };
|
||||
console.warn(`[jobs] ${job.name} failed:`, e?.message || e);
|
||||
} finally {
|
||||
entry.completedOn = new Date();
|
||||
timerHistory.set(job.name, [entry, ...(timerHistory.get(job.name) || [])].slice(0, 10));
|
||||
scheduleNext();
|
||||
}
|
||||
}, Math.min(delay, 2 ** 31 - 1)));
|
||||
};
|
||||
scheduleNext();
|
||||
}
|
||||
console.log(`[jobs] Running ${jobs.length} job(s) on in-process timers.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the background jobs. Call once after the server is listening.
|
||||
* @returns {Promise<'queue'|'timers'|'off'>}
|
||||
*/
|
||||
async function startJobs() {
|
||||
const enabled = JOBS.filter(isEnabled);
|
||||
if (!flag('JOBS_ENABLED')) {
|
||||
console.log('[jobs] All background jobs disabled by env JOBS_ENABLED=false');
|
||||
mode = 'off';
|
||||
return mode;
|
||||
}
|
||||
try {
|
||||
const PgBoss = require('pg-boss');
|
||||
boss = new PgBoss({ connectionString: process.env.DATABASE_URL, schema: SCHEMA, application_name: 'hope-events-jobs' });
|
||||
boss.on('error', (e) => console.warn('[jobs] Queue error:', e?.message || e));
|
||||
await boss.start();
|
||||
const existing = new Set((await boss.getQueues()).map(q => q.name));
|
||||
for (const job of JOBS) {
|
||||
if (!existing.has(job.name)) {
|
||||
// stately: at most one waiting and one running, so a slow run never piles up.
|
||||
await boss.createQueue(job.name, { policy: 'stately', retryLimit: 2, retryDelay: 60, expireInSeconds: 60 * 60 });
|
||||
}
|
||||
if (isEnabled(job)) {
|
||||
await boss.schedule(job.name, cronFor(job), null, { tz: TIMEZONE });
|
||||
await boss.work(job.name, async () => {
|
||||
try {
|
||||
return await runJob(job);
|
||||
} catch (e) {
|
||||
console.warn(`[jobs] ${job.name} failed:`, e?.message || e);
|
||||
throw e; // recorded as failed, and retried
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await boss.unschedule(job.name).catch(() => {});
|
||||
}
|
||||
}
|
||||
mode = 'queue';
|
||||
const off = JOBS.filter(j => !isEnabled(j)).map(j => j.name);
|
||||
console.log(`[jobs] Job queue started: ${enabled.map(j => j.name).join(', ')}${off.length ? ` (off: ${off.join(', ')})` : ''}.`);
|
||||
} catch (e) {
|
||||
console.warn('[jobs] Job queue unavailable, falling back to timers:', e?.message || e);
|
||||
if (boss) { try { await boss.stop({ graceful: false }); } catch { /* ignore */ } }
|
||||
boss = null;
|
||||
startTimers(enabled);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
async function stopJobs() {
|
||||
for (const t of timers.values()) clearTimeout(t);
|
||||
timers.clear();
|
||||
if (boss) {
|
||||
await boss.stop({ graceful: true, timeout: 10000 }).catch(() => {});
|
||||
boss = null;
|
||||
}
|
||||
mode = 'off';
|
||||
}
|
||||
|
||||
/** Runs a job now (admin "Run now"). Queue mode: queued for a worker; timer mode: inline. */
|
||||
async function runNow(name) {
|
||||
const job = JOBS.find(j => j.name === name);
|
||||
if (!job) {
|
||||
const err = new Error('Unknown job');
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
if (mode === 'queue' && isEnabled(job)) {
|
||||
const id = await boss.send(job.name, null, { singletonKey: `${job.name}-manual` });
|
||||
return { queued: true, id };
|
||||
}
|
||||
const output = await runJob(job);
|
||||
const entry = { state: 'completed', startedOn: new Date(), completedOn: new Date(), output };
|
||||
timerHistory.set(job.name, [entry, ...(timerHistory.get(job.name) || [])].slice(0, 10));
|
||||
return { queued: false, output };
|
||||
}
|
||||
|
||||
/** Each job with its schedule, next run and recent runs, for the admin page. */
|
||||
async function jobStatus(prisma) {
|
||||
const recent = new Map();
|
||||
if (mode === 'queue') {
|
||||
const rows = await prisma.$queryRawUnsafe(`
|
||||
SELECT name, state, created_on AS "createdOn", started_on AS "startedOn", completed_on AS "completedOn", output
|
||||
FROM (
|
||||
SELECT name, state, created_on, started_on, completed_on, output,
|
||||
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
|
||||
FROM (
|
||||
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job
|
||||
UNION ALL
|
||||
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.archive
|
||||
) all_jobs
|
||||
WHERE name = ANY($1::text[])
|
||||
) ranked
|
||||
WHERE rn <= 10
|
||||
ORDER BY "createdOn" DESC`, JOBS.map(j => j.name));
|
||||
for (const r of rows) recent.set(r.name, [...(recent.get(r.name) || []), r]);
|
||||
} else {
|
||||
for (const [name, list] of timerHistory) recent.set(name, list);
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
timezone: TIMEZONE,
|
||||
jobs: JOBS.map(job => {
|
||||
const cron = cronFor(job);
|
||||
const enabled = isEnabled(job);
|
||||
return {
|
||||
name: job.name, label: job.label, cron, enabled, envFlag: job.env,
|
||||
nextRun: enabled && mode !== 'off' ? nextRun(cron) : null,
|
||||
recent: recent.get(job.name) || [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { JOBS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus };
|
||||
@@ -2,9 +2,12 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listAuditLog } = require('../controllers/adminAuditController');
|
||||
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
|
||||
const { listJobs, runJobNow } = require('../controllers/jobsController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/audit-log', protect, admin, listAuditLog);
|
||||
router.get('/jobs', protect, admin, listJobs);
|
||||
router.post('/jobs/:name/run', protect, admin, runJobNow);
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getRegister, markAttendance, exportRegister,
|
||||
issueCertificates, revokeCertificate, sendCertificatesHandler, downloadEventCertificates, previewCertificate,
|
||||
myCertificates, downloadCertificate, verifyCertificate,
|
||||
} = require('../controllers/attendanceController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
// Certificates — fixed paths first so they aren't taken as an :eventId
|
||||
router.get('/certificates/verify/:code', verifyCertificate);
|
||||
router.get('/certificates/mine', protect, myCertificates);
|
||||
router.post('/certificates/preview', protect, supervisor, previewCertificate);
|
||||
router.get('/certificates/:id/pdf', protect, downloadCertificate);
|
||||
router.post('/certificates/:id/revoke', protect, supervisor, revokeCertificate);
|
||||
|
||||
// Attendance register
|
||||
router.get('/:eventId', protect, staff, getRegister);
|
||||
router.get('/:eventId/export', protect, staff, exportRegister);
|
||||
router.post('/:eventId/mark', protect, supervisor, markAttendance);
|
||||
router.post('/:eventId/certificates', protect, supervisor, issueCertificates);
|
||||
router.post('/:eventId/certificates/send', protect, supervisor, sendCertificatesHandler);
|
||||
router.get('/:eventId/certificates.pdf', protect, supervisor, downloadEventCertificates);
|
||||
|
||||
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,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getConfig, updateConfig, getWebhooks, createWebhook, removeWebhook } = require('../controllers/yocoController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/config', protect, admin, getConfig);
|
||||
router.put('/config', protect, admin, updateConfig);
|
||||
router.get('/webhooks', protect, admin, getWebhooks);
|
||||
router.post('/webhooks', protect, admin, createWebhook);
|
||||
router.delete('/webhooks/:id', protect, admin, removeWebhook);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,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,
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Completion certificates — optional per event (Event.certificatesEnabled).
|
||||
*
|
||||
* Rules:
|
||||
* - One certificate per main ticket (one person). Add-on tickets never get one.
|
||||
* - Eligible when the ticket's attendance meets the event's completion rule
|
||||
* (multi-day: sessions attended out of sessions held; single date: checked in).
|
||||
* - Issued automatically once the event has ended, or by a supervisor at any time
|
||||
* ("Issue now" for everyone eligible, or one person manually even if not eligible).
|
||||
* - A revoked certificate stays revoked: the automatic job never re-issues it.
|
||||
* - Each certificate has a short code that the public verify page checks.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const QRCode = require('qrcode');
|
||||
const prisma = require('../config/db');
|
||||
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, groupTickets, TIMEZONE } = require('./sessions');
|
||||
|
||||
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const AUTO_ISSUE_DAYS = 14;
|
||||
|
||||
/** A random code like "K7M2Q-9XRTB" (no 0/O/1/I to avoid misreading). */
|
||||
function newCode() {
|
||||
const bytes = crypto.randomBytes(10);
|
||||
const chars = Array.from(bytes, b => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
|
||||
return `${chars.slice(0, 5)}-${chars.slice(5)}`;
|
||||
}
|
||||
|
||||
/** Fills {name}, {event}, {sessionsAttended}, {totalSessions}, {endDate} in certificate text. */
|
||||
function fillPlaceholders(text, values) {
|
||||
return String(text || '').replace(/\{(name|event|sessionsAttended|totalSessions|endDate)\}/g, (_, k) => String(values[k] ?? ''));
|
||||
}
|
||||
|
||||
function fmtLongDate(d) {
|
||||
return new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function ticketName(t) {
|
||||
return t.attendee?.name || t.holder?.name || t.user?.name || 'Attendee';
|
||||
}
|
||||
|
||||
/**
|
||||
* Attendance per main ticket of an event: [{ ticket, attended, total }]. Multi-day events
|
||||
* count sessions attended (cancelled sessions left out); single-date events count 1 when
|
||||
* checked in.
|
||||
*/
|
||||
async function attendanceForCertificates(eventId) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { sessions: true } });
|
||||
if (!event) return { event: null, rows: [] };
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: { eventId, registrationOption: { eventOption: { isMainTicket: true }, registration: { status: { not: 'cancelled' } } } },
|
||||
include: {
|
||||
attendee: { select: { name: true } },
|
||||
holder: { select: { id: true, name: true } },
|
||||
user: { select: { id: true, name: true } },
|
||||
usages: { select: { quantityRedeemed: true, sessionId: true } },
|
||||
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (event.scheduleType === 'sessions') {
|
||||
const active = activeSessions(event.sessions);
|
||||
const activeIds = new Set(active.map(s => s.id));
|
||||
const bySession = await attendanceBySession(eventId);
|
||||
// A picked-session booking (one ticket per session) is one person: its first ticket
|
||||
// carries the certificate, and it's measured against the sessions it booked.
|
||||
return {
|
||||
event,
|
||||
rows: groupTickets(tickets).map(g => {
|
||||
const booked = g.sessionIds ? new Set(g.sessionIds.filter(id => activeIds.has(id))) : activeIds;
|
||||
const attendedIds = new Set();
|
||||
for (const t of g.tickets) {
|
||||
for (const [sid, qty] of (bySession.get(t.id) || new Map())) if (booked.has(sid) && qty > 0) attendedIds.add(sid);
|
||||
}
|
||||
return { ticket: g.tickets[0], attended: attendedIds.size, total: booked.size };
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { event, rows: tickets.map(t => ({ ticket: t, attended: t.usages.length > 0 ? 1 : 0, total: 1 })) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues certificates for everyone who meets the completion rule and doesn't have one yet
|
||||
* (revoked ones are left alone). Returns the new certificates.
|
||||
*/
|
||||
async function issueEligible(eventId, { issuedById = null } = {}) {
|
||||
const { event, rows } = await attendanceForCertificates(eventId);
|
||||
if (!event || !event.certificatesEnabled) return [];
|
||||
const config = normalizeCertificateConfig(event.certificateConfig);
|
||||
const existing = new Set((await prisma.certificate.findMany({ where: { eventId }, select: { ticketId: true } })).map(c => c.ticketId));
|
||||
const created = [];
|
||||
for (const r of rows) {
|
||||
if (existing.has(r.ticket.id) || !completionMet(config.rule, r.attended, r.total)) continue;
|
||||
try {
|
||||
created.push(await prisma.certificate.create({
|
||||
data: {
|
||||
eventId, ticketId: r.ticket.id, userId: r.ticket.holderUserId || r.ticket.userId,
|
||||
name: ticketName(r.ticket), code: newCode(), sessionsAttended: r.attended, totalSessions: r.total,
|
||||
issuedById,
|
||||
},
|
||||
}));
|
||||
} catch (e) {
|
||||
if (e?.code !== 'P2002') throw e; // issued at the same moment by the job — fine
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Issues (or re-issues a revoked) certificate for one ticket, eligible or not. */
|
||||
async function issueManual(eventId, ticketId, issuedById) {
|
||||
const { event, rows } = await attendanceForCertificates(eventId);
|
||||
const r = rows.find(x => x.ticket.id === ticketId);
|
||||
if (!event || !r) {
|
||||
const err = new Error('Ticket not found for this event');
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
const data = { name: ticketName(r.ticket), sessionsAttended: r.attended, totalSessions: r.total, manual: true, issuedById, revokedAt: null, issuedAt: new Date() };
|
||||
return prisma.certificate.upsert({
|
||||
where: { eventId_ticketId: { eventId, ticketId } },
|
||||
create: { eventId, ticketId, userId: r.ticket.holderUserId || r.ticket.userId, code: newCode(), ...data },
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── PDF ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE = { size: 'A4', layout: 'landscape', margin: 0 };
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
|
||||
if (!m) return [30, 58, 95];
|
||||
const n = parseInt(m[1], 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
function lighten(hex, amount) {
|
||||
const [r, g, b] = hexToRgb(hex);
|
||||
const mix = (c) => Math.round(c + (255 - c) * amount);
|
||||
return `#${[mix(r), mix(g), mix(b)].map(c => c.toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
function uploadPath(url) {
|
||||
if (!url || /^https?:\/\//i.test(url)) return null;
|
||||
const p = path.join(__dirname, '..', '..', 'public', String(url).replace(/^\//, ''));
|
||||
return fs.existsSync(p) ? p : null;
|
||||
}
|
||||
|
||||
function safeImage(doc, file, x, y, opts) {
|
||||
if (!file) return false;
|
||||
try { doc.image(file, x, y, opts); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// Shared bottom block: signature (left), date (middle), verification QR + code (right).
|
||||
function drawFooter(doc, { cert, config, branding, qr, color, font, bold, y }) {
|
||||
const W = doc.page.width;
|
||||
const colW = 200;
|
||||
const sigX = 90;
|
||||
const sigImg = uploadPath(config.signatureImage);
|
||||
if (sigImg) safeImage(doc, sigImg, sigX + 20, y - 46, { fit: [160, 42] });
|
||||
doc.moveTo(sigX, y).lineTo(sigX + colW, y).lineWidth(0.8).strokeColor('#94a3b8').stroke();
|
||||
doc.font(bold).fontSize(11).fillColor('#1e293b').text(config.signatureName || branding.name, sigX, y + 6, { width: colW, align: 'center' });
|
||||
if (config.signatureRole) doc.font(font).fontSize(9).fillColor('#64748b').text(config.signatureRole, sigX, y + 21, { width: colW, align: 'center' });
|
||||
|
||||
const dateX = W / 2 - colW / 2;
|
||||
doc.moveTo(dateX, y).lineTo(dateX + colW, y).lineWidth(0.8).strokeColor('#94a3b8').stroke();
|
||||
doc.font(bold).fontSize(11).fillColor('#1e293b').text(fmtLongDate(cert.issuedAt), dateX, y + 6, { width: colW, align: 'center' });
|
||||
doc.font(font).fontSize(9).fillColor('#64748b').text('Date', dateX, y + 21, { width: colW, align: 'center' });
|
||||
|
||||
const qrSize = 64;
|
||||
const qrX = W - 90 - qrSize;
|
||||
safeImage(doc, qr, qrX, y - 50, { width: qrSize });
|
||||
doc.font(font).fontSize(7.5).fillColor('#64748b').text(`Verify: ${cert.code}`, qrX - 40, y + 18, { width: qrSize + 80, align: 'center' });
|
||||
doc.fillColor(color);
|
||||
}
|
||||
|
||||
function drawTemplate(doc, template, ctx) {
|
||||
const { cert, config, branding, event, title, body } = ctx;
|
||||
const W = doc.page.width;
|
||||
const H = doc.page.height;
|
||||
const color = branding.brandColor;
|
||||
const logo = branding.logoPath;
|
||||
|
||||
if (template === 'modern') {
|
||||
doc.rect(0, 0, W, H).fill('#ffffff');
|
||||
doc.rect(0, 0, 28, H).fill(color);
|
||||
doc.rect(28, 0, 6, H).fill(lighten(color, 0.6));
|
||||
const x = 90;
|
||||
safeImage(doc, logo, x, 70, { fit: [120, 50] });
|
||||
doc.font('Helvetica').fontSize(11).fillColor(color).text(branding.name.toUpperCase(), x, 150, { characterSpacing: 2 });
|
||||
doc.font('Helvetica-Bold').fontSize(36).fillColor('#0f172a').text(title, x, 175, { width: W - x - 80 });
|
||||
doc.font('Helvetica').fontSize(13).fillColor('#64748b').text('Presented to', x, 260);
|
||||
doc.font('Helvetica-Bold').fontSize(32).fillColor(color).text(cert.name, x, 280, { width: W - x - 80 });
|
||||
doc.font('Helvetica').fontSize(14).fillColor('#334155').text(body, x, 340, { width: W - x - 140, lineGap: 5 });
|
||||
drawFooter(doc, { ...ctx, color, font: 'Helvetica', bold: 'Helvetica-Bold', y: H - 80 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (template === 'church') {
|
||||
doc.rect(0, 0, W, H).fill(lighten(color, 0.94));
|
||||
doc.rect(30, 30, W - 60, H - 60).lineWidth(1).strokeColor(lighten(color, 0.4)).stroke();
|
||||
const hasLogo = safeImage(doc, logo, W / 2 - 50, 55, { fit: [100, 70], align: 'center' });
|
||||
const top = hasLogo ? 150 : 115;
|
||||
doc.font('Times-Italic').fontSize(14).fillColor(color).text(branding.name, 0, top, { width: W, align: 'center' });
|
||||
doc.font('Times-Bold').fontSize(34).fillColor('#1e293b').text(title, 60, top + 26, { width: W - 120, align: 'center' });
|
||||
doc.moveTo(W / 2 - 60, top + 74).lineTo(W / 2 + 60, top + 74).lineWidth(1.5).strokeColor(color).stroke();
|
||||
doc.font('Times-Italic').fontSize(14).fillColor('#475569').text('This is to certify that', 0, top + 88, { width: W, align: 'center' });
|
||||
doc.font('Times-Bold').fontSize(30).fillColor(color).text(cert.name, 60, top + 110, { width: W - 120, align: 'center' });
|
||||
doc.font('Times-Roman').fontSize(14).fillColor('#334155').text(body, 120, top + 162, { width: W - 240, align: 'center', lineGap: 5 });
|
||||
drawFooter(doc, { ...ctx, color, font: 'Times-Roman', bold: 'Times-Bold', y: H - 85 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (template === 'kids') {
|
||||
const bright = ['#f59e0b', '#ec4899', '#22c55e', '#3b82f6', '#a855f7'];
|
||||
doc.rect(0, 0, W, H).fill('#fffdf5');
|
||||
doc.roundedRect(22, 22, W - 44, H - 44, 28).lineWidth(10).strokeColor(color).stroke();
|
||||
// Confetti dots around the edge.
|
||||
let seed = parseInt(cert.code.replace(/[^A-Z0-9]/g, '').slice(0, 6), 36) || 7;
|
||||
const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
|
||||
// Confetti in the top band and down the sides — never over the text or the footer.
|
||||
for (let i = 0; i < 46; i++) {
|
||||
const top = rnd() < 0.45;
|
||||
const x = top ? 50 + rnd() * (W - 100) : (rnd() < 0.5 ? 48 + rnd() * 45 : W - 93 + rnd() * 45);
|
||||
const y = top ? 48 + rnd() * 50 : 110 + rnd() * (H - 260);
|
||||
doc.circle(x, y, 3 + rnd() * 5).fill(bright[i % bright.length]);
|
||||
}
|
||||
safeImage(doc, logo, W / 2 - 40, 100, { fit: [80, 50], align: 'center' });
|
||||
doc.font('Helvetica-Bold').fontSize(38).fillColor(color).text(title, 100, 165, { width: W - 200, align: 'center' });
|
||||
doc.font('Helvetica-Bold').fontSize(15).fillColor('#f59e0b').text('Well done,', 0, 230, { width: W, align: 'center' });
|
||||
doc.font('Helvetica-Bold').fontSize(36).fillColor('#1e293b').text(cert.name, 100, 250, { width: W - 200, align: 'center' });
|
||||
doc.font('Helvetica').fontSize(15).fillColor('#334155').text(body, 130, 310, { width: W - 260, align: 'center', lineGap: 5 });
|
||||
drawFooter(doc, { ...ctx, color, font: 'Helvetica', bold: 'Helvetica-Bold', y: H - 90 });
|
||||
return;
|
||||
}
|
||||
|
||||
// classic
|
||||
doc.rect(0, 0, W, H).fill('#fffefa');
|
||||
doc.rect(24, 24, W - 48, H - 48).lineWidth(3).strokeColor(color).stroke();
|
||||
doc.rect(34, 34, W - 68, H - 68).lineWidth(0.8).strokeColor(color).stroke();
|
||||
for (const [cx, cy] of [[34, 34], [W - 34, 34], [34, H - 34], [W - 34, H - 34]]) {
|
||||
doc.circle(cx, cy, 6).fill(color);
|
||||
}
|
||||
safeImage(doc, logo, W / 2 - 45, 70, { fit: [90, 55], align: 'center' });
|
||||
doc.font('Times-Bold').fontSize(38).fillColor('#1e293b').text(title.toUpperCase(), 60, 150, { width: W - 120, align: 'center', characterSpacing: 1.5 });
|
||||
doc.font('Times-Italic').fontSize(14).fillColor('#64748b').text('This certificate is proudly presented to', 0, 212, { width: W, align: 'center' });
|
||||
doc.font('Times-BoldItalic').fontSize(34).fillColor(color).text(cert.name, 60, 236, { width: W - 120, align: 'center' });
|
||||
doc.moveTo(W / 2 - 170, 284).lineTo(W / 2 + 170, 284).lineWidth(0.8).strokeColor('#cbd5e1').stroke();
|
||||
doc.font('Times-Roman').fontSize(14).fillColor('#334155').text(body, 120, 300, { width: W - 240, align: 'center', lineGap: 5 });
|
||||
doc.font('Times-Italic').fontSize(11).fillColor('#64748b').text(branding.name, 0, H - 132, { width: W, align: 'center' });
|
||||
drawFooter(doc, { ...ctx, color, font: 'Times-Roman', bold: 'Times-Bold', y: H - 85 });
|
||||
void event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders certificates (one page each) to a PDF Buffer.
|
||||
* @param {object[]} certs - Certificate rows ({ name, code, issuedAt, sessionsAttended, totalSessions })
|
||||
* @param {object} event - { title, endDate, certificateConfig }
|
||||
* @param {object} [configOverride] - certificate settings to use instead (editor preview)
|
||||
*/
|
||||
async function renderCertificatesPdf(certs, event, configOverride = null) {
|
||||
const { getBranding } = require('./pdfDocs');
|
||||
const branding = await getBranding();
|
||||
const config = normalizeCertificateConfig(configOverride || event.certificateConfig);
|
||||
const doc = new PDFDocument({ ...PAGE, autoFirstPage: false, info: { Title: `${config.title} — ${event.title}` } });
|
||||
const chunks = [];
|
||||
doc.on('data', c => chunks.push(c));
|
||||
const done = new Promise((resolve, reject) => { doc.on('end', resolve); doc.on('error', reject); });
|
||||
|
||||
for (const cert of certs) {
|
||||
doc.addPage(PAGE);
|
||||
const values = { name: cert.name, event: event.title, sessionsAttended: cert.sessionsAttended, totalSessions: cert.totalSessions, endDate: fmtLongDate(event.endDate) };
|
||||
const qrData = await QRCode.toDataURL(`${branding.url}/certificates/verify/${encodeURIComponent(cert.code)}`, { margin: 0, width: 200 });
|
||||
const qr = Buffer.from(qrData.split(',')[1], 'base64');
|
||||
drawTemplate(doc, config.template, {
|
||||
cert, config, branding, event, qr,
|
||||
title: fillPlaceholders(config.title, values),
|
||||
body: fillPlaceholders(config.body, values),
|
||||
});
|
||||
}
|
||||
doc.end();
|
||||
await done;
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/** A sample certificate for the event editor's preview button. */
|
||||
function sampleCertificate() {
|
||||
return { name: 'Thandi Mokoena', code: 'SAMPL-E2026', issuedAt: new Date(), sessionsAttended: 5, totalSessions: 5 };
|
||||
}
|
||||
|
||||
// ─── Sending ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Emails (and WhatsApps, per preference) each certificate to its person. Returns how many went out. */
|
||||
async function sendCertificates(certIds) {
|
||||
const { sendMail, emailWrapper, ctaButton, getOrg } = require('./email');
|
||||
const { shouldEmail, waPdf, waPdfAny } = require('./notify');
|
||||
const certs = await prisma.certificate.findMany({
|
||||
where: { id: { in: certIds }, revokedAt: null },
|
||||
include: {
|
||||
event: { select: { id: true, title: true, endDate: true, certificateConfig: true } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
|
||||
},
|
||||
});
|
||||
const org = getOrg();
|
||||
let sent = 0;
|
||||
for (const cert of certs) {
|
||||
const user = cert.user;
|
||||
if (!user) continue;
|
||||
try {
|
||||
const pdf = await renderCertificatesPdf([cert], cert.event);
|
||||
const filename = `Certificate - ${cert.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80) + '.pdf';
|
||||
const hasEmail = user.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||
const jobs = [];
|
||||
if (hasEmail && shouldEmail(user)) {
|
||||
const html = emailWrapper(`
|
||||
<p>Hi ${user.name?.split(' ')[0] || 'there'},</p>
|
||||
<p>Congratulations on completing <strong>${cert.event.title}</strong>! Your certificate for <strong>${cert.name}</strong> is attached.</p>
|
||||
${ctaButton('View my events', `${org.url}/dashboard/user`)}
|
||||
<p style="font-size:13px;color:#64748b">Anyone can check it at ${org.url}/certificates/verify/${cert.code}</p>`, { preheader: `Your certificate for ${cert.event.title}` });
|
||||
jobs.push(sendMail({ to: user.email, subject: `Your certificate — ${cert.event.title}`, html, text: `Congratulations on completing ${cert.event.title}! Your certificate is attached.`, attachments: [{ filename, content: pdf, contentType: 'application/pdf' }] }));
|
||||
}
|
||||
const { tempDir } = require('./pdfDocs');
|
||||
const tmp = path.join(tempDir(), `certificate-${cert.id}.pdf`);
|
||||
fs.writeFileSync(tmp, pdf);
|
||||
const caption = `🎓 Your certificate for ${cert.event.title}`;
|
||||
jobs.push(hasEmail ? waPdf(user, tmp, filename, caption) : waPdfAny(user, tmp, filename, caption));
|
||||
await Promise.all(jobs);
|
||||
await prisma.certificate.update({ where: { id: cert.id }, data: { emailedAt: new Date() } });
|
||||
sent += 1;
|
||||
} catch (e) {
|
||||
console.warn('[certificates] Send failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background job: for events with certificates on that ended within the last 14 days,
|
||||
* issue certificates to everyone eligible, and send them when the event says so.
|
||||
*/
|
||||
async function runCertificateJob(now = new Date()) {
|
||||
const since = new Date(now.getTime() - AUTO_ISSUE_DAYS * 24 * 60 * 60 * 1000);
|
||||
const events = await prisma.event.findMany({
|
||||
where: { certificatesEnabled: true, endDate: { lte: now, gte: since } },
|
||||
select: { id: true, certificateConfig: true },
|
||||
});
|
||||
let issued = 0;
|
||||
let sent = 0;
|
||||
for (const ev of events) {
|
||||
const created = await issueEligible(ev.id);
|
||||
issued += created.length;
|
||||
if (created.length && normalizeCertificateConfig(ev.certificateConfig).sendOnIssue) {
|
||||
sent += await sendCertificates(created.map(c => c.id));
|
||||
}
|
||||
}
|
||||
return { issued, sent };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
newCode, fillPlaceholders, attendanceForCertificates, issueEligible, issueManual,
|
||||
renderCertificatesPdf, sampleCertificate, sendCertificates, runCertificateJob,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -423,6 +423,7 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
|
||||
module.exports = {
|
||||
sendMail,
|
||||
isSmtpConfigured,
|
||||
getOrg,
|
||||
emailWrapper,
|
||||
ctaButton,
|
||||
fallbackLink,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { createEvent } = require('ics');
|
||||
const { createEvent, createEvents } = require('ics');
|
||||
|
||||
// Event.startDate/endDate are stored as true UTC instants (the admin event form's
|
||||
// datetime-local input is parsed in the browser's local time before being sent as an
|
||||
@@ -9,26 +9,46 @@ function toUtcArray(date) {
|
||||
return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an RFC 5545 .ics file (as a string) for a single event.
|
||||
*
|
||||
* @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?
|
||||
* @param {string} eventUrl - absolute URL to the event's public page
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildEventIcs(event, eventUrl) {
|
||||
const { error, value } = createEvent({
|
||||
title: event.title,
|
||||
start: toUtcArray(event.startDate),
|
||||
end: toUtcArray(event.endDate),
|
||||
function icsEntry(event, eventUrl, { start, end, title, location }) {
|
||||
return {
|
||||
title,
|
||||
start: toUtcArray(start),
|
||||
end: toUtcArray(end),
|
||||
startInputType: 'utc',
|
||||
endInputType: 'utc',
|
||||
startOutputType: 'utc',
|
||||
endOutputType: 'utc',
|
||||
location: event.location || undefined,
|
||||
location: location || event.location || undefined,
|
||||
description: event.description || undefined,
|
||||
url: eventUrl,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an RFC 5545 .ics file (as a string) for an event. A multi-session event gets one
|
||||
* calendar entry per session that isn't cancelled (not a repeat rule, so moved or skipped
|
||||
* sessions are exact).
|
||||
*
|
||||
* @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?,
|
||||
* scheduleType?, sessions?[]
|
||||
* @param {string} eventUrl - absolute URL to the event's public page
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildEventIcs(event, eventUrl) {
|
||||
const sessions = event.scheduleType === 'sessions'
|
||||
? (event.sessions || []).filter(s => !s.cancelledAt).sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
|
||||
: [];
|
||||
if (sessions.length > 0) {
|
||||
const { error, value } = createEvents(sessions.map((s, i) => icsEntry(event, eventUrl, {
|
||||
start: s.startsAt,
|
||||
end: s.endsAt,
|
||||
title: `${event.title} — ${s.label || `Session ${i + 1}`}`,
|
||||
location: s.location,
|
||||
})));
|
||||
if (error) throw error;
|
||||
return value;
|
||||
}
|
||||
const { error, value } = createEvent(icsEntry(event, eventUrl, { start: event.startDate, end: event.endDate, title: event.title }));
|
||||
if (error) throw error;
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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, tempDir };
|
||||
|
||||
@@ -25,10 +25,12 @@
|
||||
* - 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');
|
||||
const { withLatePrice } = require('./sessions');
|
||||
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
@@ -90,8 +92,9 @@ function getEffectiveUnitPrice(eventOption, referenceTime, atTime) {
|
||||
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
|
||||
* @returns {Promise<{ price: number, tierId: string|null }>}
|
||||
*/
|
||||
// Late joiners on a multi-day event pay a lower main-ticket price (utils/sessions.js).
|
||||
async function resolveOptionPrice(option, requestedQty = 1, user = null) {
|
||||
return withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user));
|
||||
return withLatePrice(option, withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user)));
|
||||
}
|
||||
|
||||
async function resolveOptionTierPrice(option, requestedQty) {
|
||||
@@ -146,7 +149,7 @@ async function resolveOptionTierPrice(option, requestedQty) {
|
||||
* @returns {Promise<{ price: number, tierId: string|null }>}
|
||||
*/
|
||||
async function resolveVariantTierPrice(option, variantId, requestedQty = 1, user = null) {
|
||||
return withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user));
|
||||
return withLatePrice(option, withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user)));
|
||||
}
|
||||
|
||||
async function resolveVariantOnlyTierPrice(option, variantId, requestedQty) {
|
||||
@@ -269,7 +272,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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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.
|
||||
* - Multi-day events: the snapshot counts what's left for the session running when it was
|
||||
* downloaded, and each synced scan counts for the session at its scan time.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
@@ -53,6 +55,8 @@ function lockCovers(lock, { sectionId = null, optionId }) {
|
||||
function judgeSyncedScan(ticket, qty) {
|
||||
if (!ticket) return { redeem: 0, conflict: 'not_found' };
|
||||
if (ticket.transferred) return { redeem: 0, conflict: 'transferred' };
|
||||
if (ticket.noSession) return { redeem: 0, conflict: 'no_session' };
|
||||
if (ticket.wrongSession) return { redeem: 0, conflict: 'wrong_session' };
|
||||
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' };
|
||||
@@ -69,6 +73,8 @@ const CONFLICT_TEXT = {
|
||||
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',
|
||||
no_session: 'Scanned on a day (or at a time) with no session open for check-in',
|
||||
wrong_session: 'Ticket is for a different session',
|
||||
};
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
@@ -123,7 +129,7 @@ async function snapshotFor(lock) {
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } }, ...(optionIds ? { eventOptionId: { in: optionIds } } : {}) },
|
||||
},
|
||||
include: {
|
||||
usages: { select: { quantityRedeemed: true } },
|
||||
usages: { select: { quantityRedeemed: true, sessionId: true } },
|
||||
attendee: { select: { name: true } },
|
||||
user: { select: { name: true } },
|
||||
registrationOption: { include: { eventOption: { select: { id: true, name: true, isMainTicket: true } }, variant: { select: { name: true } } } },
|
||||
@@ -133,10 +139,19 @@ async function snapshotFor(lock) {
|
||||
const consent = await consentStatusForTickets(tickets.map(t => t.id));
|
||||
const snapshotAt = new Date();
|
||||
await prisma.scanLock.update({ where: { id: lock.id }, data: { snapshotAt } });
|
||||
const sessionsUtil = require('./sessions');
|
||||
const schedule = await sessionsUtil.eventSchedule(lock.eventId);
|
||||
const session = schedule?.scheduleType === 'sessions' ? sessionsUtil.currentSession(schedule.sessions, snapshotAt, schedule.checkInWindow) : null;
|
||||
return {
|
||||
snapshotAt,
|
||||
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
|
||||
noSessionToday: schedule?.scheduleType === 'sessions' && !session,
|
||||
tickets: tickets.map(t => {
|
||||
const redeemed = t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
// A picked-session ticket for another session has nothing left today.
|
||||
const otherSession = !!(t.registrationOption.sessionId && t.registrationOption.sessionId !== session?.id);
|
||||
const redeemed = schedule?.scheduleType === 'sessions'
|
||||
? (session && !otherSession ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
|
||||
: t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
return {
|
||||
id: t.id,
|
||||
qrCode: t.qrCode,
|
||||
@@ -161,6 +176,9 @@ async function syncScans(lock, scans) {
|
||||
const optionIds = lock.section ? new Set(lock.section.allowedOptions.map(o => o.eventOptionId)) : null;
|
||||
const { consentStatusForTickets } = require('./consent');
|
||||
const checkIns = [];
|
||||
const sessionsUtil = require('./sessions');
|
||||
const schedule = await sessionsUtil.eventSchedule(lock.eventId);
|
||||
const hasSessions = schedule?.scheduleType === 'sessions';
|
||||
|
||||
for (const scan of (scans || []).slice(0, 2000)) {
|
||||
const clientScanId = String(scan?.clientScanId || '').slice(0, 100);
|
||||
@@ -178,9 +196,15 @@ async function syncScans(lock, scans) {
|
||||
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 session = hasSessions ? sessionsUtil.currentSession(schedule.sessions, scannedAt, schedule.checkInWindow) : null;
|
||||
const redeemed = session
|
||||
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
|
||||
: ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id);
|
||||
state = {
|
||||
sessionId: session?.id || null,
|
||||
noSession: hasSessions && !session,
|
||||
wrongSession: !!(session && ticket.registrationOption.sessionId && ticket.registrationOption.sessionId !== session.id),
|
||||
remaining: (ticket.quantity || 1) - redeemed,
|
||||
cancelled: ticket.registrationOption.registration.status === 'cancelled',
|
||||
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
|
||||
@@ -190,9 +214,9 @@ async function syncScans(lock, scans) {
|
||||
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() },
|
||||
data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date(), sessionId: state.sessionId },
|
||||
});
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: state.remaining - verdict.redeem <= 0, updatedAt: new Date() } });
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: !hasSessions && 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 });
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Session reminders — optional per multi-day event (Event.sessionReminders), e.g. "3 h
|
||||
* before" and "1 day before" each session, by email and/or WhatsApp.
|
||||
*
|
||||
* Rules:
|
||||
* - Goes to everyone with a ticket for the session (the booker, and named attendees with
|
||||
* an account), skipping cancelled registrations. One message per person per session.
|
||||
* - Each (session, offset) is sent once (SessionReminderLog). When several offsets are
|
||||
* already due — e.g. reminders were switched on 2 h before a session — only the
|
||||
* closest one is sent and the others are logged as skipped, so nobody gets two at once.
|
||||
* - Cancelled sessions get no reminders. Email/WhatsApp follow each person's
|
||||
* notification preference.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { activeSessions, sessionLabel, TIMEZONE } = require('./sessions');
|
||||
|
||||
const MAX_OFFSET_MIN = 1440;
|
||||
|
||||
function fmtWhen(d) {
|
||||
return new Date(d).toLocaleString('en-GB', { timeZone: TIMEZONE, weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** People to remind about a session: whole-course tickets plus picked places on it. Map userId → user. */
|
||||
async function recipientsFor(eventId, sessionId) {
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } }, OR: [{ sessionId: null }, { sessionId }] } },
|
||||
select: {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
|
||||
holder: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
|
||||
},
|
||||
});
|
||||
const out = new Map();
|
||||
for (const t of tickets) {
|
||||
for (const u of [t.user, t.holder]) if (u && !out.has(u.id)) out.set(u.id, u);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function sendReminder(user, event, session, sessions) {
|
||||
const { sendMail, emailWrapper, ctaButton, getOrg } = require('./email');
|
||||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||
const org = getOrg();
|
||||
const label = sessionLabel(session, sessions);
|
||||
const when = fmtWhen(session.startsAt);
|
||||
const where = session.location || event.location || '';
|
||||
const first = (user.name || '').split(' ')[0] || 'there';
|
||||
const url = `${org.url}/events/${event.id}`;
|
||||
const jobs = [];
|
||||
const hasEmail = user.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||
if (hasEmail && shouldEmail(user)) {
|
||||
const html = emailWrapper(`
|
||||
<p>Hi ${first},</p>
|
||||
<p>A reminder that <strong>${event.title} — ${label}</strong> is on <strong>${when}</strong>${where ? ` at <strong>${where}</strong>` : ''}.</p>
|
||||
<p>Bring your ticket QR code (it's the same one for every session).</p>
|
||||
${ctaButton('View event', url)}`, { preheader: `${label} — ${when}` });
|
||||
jobs.push(sendMail({ to: user.email, subject: `Reminder: ${event.title} — ${label}`, html, text: `Reminder: ${event.title} — ${label} is on ${when}${where ? ` at ${where}` : ''}. ${url}` }));
|
||||
}
|
||||
const wa = `⏰ *Reminder*\n\nHi ${first}, *${event.title} — ${label}* is on ${when}${where ? `\n📍 ${where}` : ''}.\n\nBring your ticket QR code — it's the same one for every session.\n${url}`;
|
||||
jobs.push(hasEmail ? waText(user, wa) : waTextAny(user, wa));
|
||||
await Promise.all(jobs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the reminders that are due. Returns { sessions, sent } counts.
|
||||
*/
|
||||
async function runSessionReminderJob(now = new Date()) {
|
||||
// (Filtering a Json column on null is awkward in Prisma, so events without reminders
|
||||
// are skipped below instead.)
|
||||
const events = await prisma.event.findMany({
|
||||
where: { scheduleType: 'sessions', isActive: true, endDate: { gte: now } },
|
||||
include: {
|
||||
sessions: {
|
||||
where: { cancelledAt: null, startsAt: { gt: now, lte: new Date(now.getTime() + MAX_OFFSET_MIN * 60 * 1000) } },
|
||||
include: { reminders: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
let sessionsDone = 0;
|
||||
let sent = 0;
|
||||
for (const event of events) {
|
||||
if (!event.sessionReminders || event.sessions.length === 0) continue;
|
||||
const config = event.sessionReminders;
|
||||
const offsets = (config.offsets || []).map(Number).filter(n => n > 0).sort((a, b) => a - b);
|
||||
if (offsets.length === 0) continue;
|
||||
const allSessions = await prisma.eventSession.findMany({ where: { eventId: event.id } });
|
||||
for (const session of event.sessions) {
|
||||
const minutesLeft = (new Date(session.startsAt) - now) / 60000;
|
||||
const loggedOffsets = new Set(session.reminders.map(r => r.offset));
|
||||
const due = offsets.filter(o => minutesLeft <= o && !loggedOffsets.has(o));
|
||||
if (due.length === 0) continue;
|
||||
// Claim every due offset first (unique per session+offset), so a second server
|
||||
// process running the same job can't send them again.
|
||||
const claimed = [];
|
||||
for (const o of due) {
|
||||
try {
|
||||
await prisma.sessionReminderLog.create({ data: { sessionId: session.id, offset: o } });
|
||||
claimed.push(o);
|
||||
} catch (e) {
|
||||
if (e?.code !== 'P2002') throw e;
|
||||
}
|
||||
}
|
||||
if (!claimed.includes(due[0])) continue; // the closest one was claimed elsewhere
|
||||
const recipients = await recipientsFor(event.id, session.id);
|
||||
let count = 0;
|
||||
for (const user of recipients.values()) {
|
||||
try {
|
||||
await sendReminder(user, event, session, activeSessions(allSessions));
|
||||
count += 1;
|
||||
} catch (e) {
|
||||
console.warn('[session reminders] Send failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
await prisma.sessionReminderLog.update({ where: { sessionId_offset: { sessionId: session.id, offset: due[0] } }, data: { sent: count } });
|
||||
sessionsDone += 1;
|
||||
sent += count;
|
||||
}
|
||||
}
|
||||
return { sessions: sessionsDone, sent };
|
||||
}
|
||||
|
||||
module.exports = { runSessionReminderJob };
|
||||
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* Multi-day events — one event running on several dates ("sessions"), e.g. every
|
||||
* Wednesday for 5 weeks.
|
||||
*
|
||||
* Rules:
|
||||
* - Event.startDate/endDate always mirror the first active session's start and the last
|
||||
* active session's end, so everything that only reads those keeps working.
|
||||
* - A cancelled session is kept (it may have check-ins) but is hidden from the schedule
|
||||
* and left out of totals. A session with check-ins can't be deleted, only cancelled.
|
||||
* - Check-in is once per session. "Whole day" accepts a scan any time on the session's
|
||||
* date (in the church's timezone); "session only" from 2 h before the start until the end.
|
||||
* - Registration closes when the first session starts, or — with late joining — when
|
||||
* session N starts. A registration deadline, if set, still wins when it's earlier.
|
||||
* - Late pricing applies to main tickets only, for people registering after the first
|
||||
* session has started: pro-rata on the sessions left (nearest R10), or a fixed price.
|
||||
* - "Pick sessions" events (attendanceMode pick): options with sessionMode `pick` are
|
||||
* bought per session — one registration line (and ticket) per session, priced per
|
||||
* session, bookable until that session starts. `all` options cover every session and
|
||||
* follow the whole-course rules above. A session's capacity counts picked places plus
|
||||
* whole-course main tickets; a pick option's stock limit is places per session.
|
||||
* Not available on youth (parental consent) events, and picked lines take no names.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
|
||||
const EARLY_CHECK_IN_MS = 2 * 60 * 60 * 1000;
|
||||
const MAX_SESSIONS = 200;
|
||||
const REMINDER_OFFSETS = [60, 180, 1440]; // minutes before start: 1 h, 3 h, 1 day
|
||||
const CERTIFICATE_TEMPLATES = ['classic', 'modern', 'church', 'kids'];
|
||||
|
||||
function sessionError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 'YYYY-MM-DD' of a moment in the church's timezone. */
|
||||
function dayKey(date, timeZone = TIMEZONE) {
|
||||
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(date));
|
||||
}
|
||||
|
||||
/** Sessions that aren't cancelled, earliest first. */
|
||||
function activeSessions(sessions) {
|
||||
return (sessions || [])
|
||||
.filter(s => !s.cancelledAt)
|
||||
.sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt));
|
||||
}
|
||||
|
||||
/** { startDate, endDate } covering the active sessions, or null when there are none. */
|
||||
function scheduleBounds(sessions) {
|
||||
const active = activeSessions(sessions);
|
||||
if (active.length === 0) return null;
|
||||
const endDate = active.reduce((max, s) => (new Date(s.endsAt) > max ? new Date(s.endsAt) : max), new Date(active[0].endsAt));
|
||||
return { startDate: new Date(active[0].startsAt), endDate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks and tidies the sessions sent by the event editor.
|
||||
* Each: { id?, label?, startsAt, endsAt, location?, cancelled? }.
|
||||
*/
|
||||
function normalizeSessionsInput(list) {
|
||||
if (!Array.isArray(list)) throw sessionError('Sessions must be a list');
|
||||
if (list.length > MAX_SESSIONS) throw sessionError(`An event can have at most ${MAX_SESSIONS} sessions`);
|
||||
const out = list.map((s, i) => {
|
||||
const startsAt = new Date(s?.startsAt);
|
||||
const endsAt = new Date(s?.endsAt);
|
||||
if (isNaN(startsAt) || isNaN(endsAt)) throw sessionError(`Session ${i + 1} needs a start and end date`);
|
||||
if (endsAt <= startsAt) throw sessionError(`Session ${i + 1} must end after it starts`);
|
||||
return {
|
||||
id: s.id ? String(s.id) : null,
|
||||
label: s.label ? String(s.label).trim().slice(0, 120) || null : null,
|
||||
startsAt,
|
||||
endsAt,
|
||||
location: s.location ? String(s.location).trim().slice(0, 300) || null : null,
|
||||
cancelled: s.cancelled === true,
|
||||
capacity: s.capacity === null || s.capacity === undefined || s.capacity === '' ? null : Math.max(1, parseInt(s.capacity, 10) || 1),
|
||||
};
|
||||
});
|
||||
out.sort((a, b) => a.startsAt - b.startsAt);
|
||||
if (!out.some(s => !s.cancelled)) throw sessionError('A multi-session event needs at least one session that isn\'t cancelled');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a check-in for `session` is allowed at `now`. */
|
||||
function sessionAcceptsCheckIn(session, now = new Date(), window = 'whole_day') {
|
||||
if (!session || session.cancelledAt) return false;
|
||||
const t = new Date(now);
|
||||
if (window === 'session_only') {
|
||||
return t >= new Date(new Date(session.startsAt).getTime() - EARLY_CHECK_IN_MS) && t <= new Date(session.endsAt);
|
||||
}
|
||||
const today = dayKey(t);
|
||||
return today >= dayKey(session.startsAt) && today <= dayKey(session.endsAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session a scan at `now` counts for, or null. On a day with more than one session:
|
||||
* the one happening now (from 2 h before), else the next one today, else the last one.
|
||||
*/
|
||||
function currentSession(sessions, now = new Date(), window = 'whole_day') {
|
||||
const t = new Date(now);
|
||||
const open = activeSessions(sessions).filter(s => sessionAcceptsCheckIn(s, t, window));
|
||||
if (open.length <= 1) return open[0] || null;
|
||||
const running = open.find(s => t >= new Date(new Date(s.startsAt).getTime() - EARLY_CHECK_IN_MS) && t <= new Date(s.endsAt));
|
||||
if (running) return running;
|
||||
return open.find(s => new Date(s.startsAt) > t) || open[open.length - 1];
|
||||
}
|
||||
|
||||
/** When public registration closes for an event (Date). */
|
||||
/** When whole-course (all-sessions) tickets stop selling (Date). */
|
||||
function wholeCourseClosesAt(event, sessions) {
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
let closes = new Date(event.endDate);
|
||||
if (event.scheduleType === 'sessions') {
|
||||
const active = activeSessions(sessions);
|
||||
if (active.length > 0) {
|
||||
const until = event.lateJoinEnabled ? active.find(s => s.id === event.lateJoinUntilSessionId) : null;
|
||||
closes = new Date((until || active[0]).startsAt);
|
||||
}
|
||||
}
|
||||
return deadline && deadline < closes ? deadline : closes;
|
||||
}
|
||||
|
||||
/**
|
||||
* When public registration closes (Date). "Pick sessions" events stay open until the
|
||||
* last session starts (each session can be booked until it starts).
|
||||
*/
|
||||
function registrationClosesAt(event, sessions) {
|
||||
const whole = wholeCourseClosesAt(event, sessions);
|
||||
if (event.scheduleType !== 'sessions' || event.attendanceMode !== 'pick') return whole;
|
||||
const active = activeSessions(sessions);
|
||||
if (active.length === 0) return whole;
|
||||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||||
const last = new Date(active[active.length - 1].startsAt);
|
||||
const closes = last > whole ? last : whole;
|
||||
return deadline && deadline < closes ? deadline : closes;
|
||||
}
|
||||
|
||||
/** Whether an option is bought per session (only in "pick sessions" events). */
|
||||
function isPickOption(event, option) {
|
||||
return event?.scheduleType === 'sessions' && event?.attendanceMode === 'pick' && option?.sessionMode === 'pick';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns requested lines into registration lines: a pick option with sessionIds becomes
|
||||
* one line per session (same quantity); other options stay as they are. Checks the
|
||||
* sessions can still be booked: public — before the session starts; staff — until it ends.
|
||||
* @param {object} event - with eventOptions and sessions
|
||||
* @param {object[]} lines - [{ eventOptionId, variantId?, quantity, sessionIds? }]
|
||||
*/
|
||||
function expandPickLines(event, lines, { now = new Date(), staff = false } = {}) {
|
||||
const out = [];
|
||||
for (const line of lines || []) {
|
||||
const option = (event.eventOptions || []).find(o => o.id === line.eventOptionId);
|
||||
const pick = isPickOption(event, option);
|
||||
const ids = Array.isArray(line.sessionIds) ? [...new Set(line.sessionIds.map(String))] : [];
|
||||
if (!pick) {
|
||||
if (ids.length) throw sessionError(`"${option?.name || 'This ticket'}" covers every session — no sessions to choose.`);
|
||||
out.push({ ...line, sessionId: null });
|
||||
continue;
|
||||
}
|
||||
if (event.requiresParentalConsent) throw sessionError('Choosing sessions isn\'t available on youth events.');
|
||||
if (ids.length === 0) throw sessionError(`Choose the sessions for "${option.name}".`);
|
||||
for (const id of ids) {
|
||||
const session = (event.sessions || []).find(s => s.id === id);
|
||||
if (!session || session.cancelledAt) throw sessionError(`A chosen session for "${option.name}" isn't available.`);
|
||||
const cutoff = staff ? new Date(session.endsAt) : new Date(session.startsAt);
|
||||
if (cutoff <= now) throw sessionError(`${sessionLabel(session, event.sessions)} has ${staff ? 'ended' : 'already started'} — it can't be booked any more.`);
|
||||
const { sessionIds, ...rest } = line;
|
||||
void sessionIds;
|
||||
out.push({ ...rest, sessionId: id });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* People booked per session: picked places on that session plus whole-course main
|
||||
* tickets (which attend every session). Cancelled registrations don't count.
|
||||
* @returns {Promise<Map<string, number>>}
|
||||
*/
|
||||
async function sessionBookings(eventId, db = prisma) {
|
||||
const lines = await db.registrationOption.findMany({
|
||||
where: { registration: { eventId, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
|
||||
select: { quantity: true, sessionId: true },
|
||||
});
|
||||
const sessions = await db.eventSession.findMany({ where: { eventId, cancelledAt: null }, select: { id: true } });
|
||||
const whole = lines.filter(l => !l.sessionId).reduce((s, l) => s + (l.quantity || 0), 0);
|
||||
const out = new Map(sessions.map(s => [s.id, whole]));
|
||||
for (const l of lines) if (l.sessionId && out.has(l.sessionId)) out.set(l.sessionId, out.get(l.sessionId) + (l.quantity || 0));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks requested lines fit each session's capacity. Whole-course main tickets count
|
||||
* against every session; picked main tickets against their own.
|
||||
*/
|
||||
async function assertSessionCapacity(event, lines, db = prisma) {
|
||||
const limited = activeSessions(event.sessions).filter(s => s.capacity > 0);
|
||||
if (limited.length === 0) return;
|
||||
const mains = new Set((event.eventOptions || []).filter(o => o.isMainTicket).map(o => o.id));
|
||||
const wanted = new Map();
|
||||
const whole = lines.filter(l => !l.sessionId && mains.has(l.eventOptionId)).reduce((s, l) => s + (Number(l.quantity) || 0), 0);
|
||||
for (const s of limited) wanted.set(s.id, whole);
|
||||
for (const l of lines) if (l.sessionId && mains.has(l.eventOptionId) && wanted.has(l.sessionId)) wanted.set(l.sessionId, wanted.get(l.sessionId) + (Number(l.quantity) || 0));
|
||||
if ([...wanted.values()].every(n => n === 0)) return;
|
||||
const booked = await sessionBookings(event.id, db);
|
||||
for (const s of limited) {
|
||||
const left = s.capacity - (booked.get(s.id) || 0);
|
||||
if ((wanted.get(s.id) || 0) > left) {
|
||||
throw sessionError(`${sessionLabel(s, event.sessions)} is ${left <= 0 ? 'full' : `nearly full — only ${left} place${left === 1 ? '' : 's'} left`}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Late-joiner pricing state at `now`: active once the first session has started (for
|
||||
* events with late joining and a late price). sessionsLeft counts sessions not yet started.
|
||||
*/
|
||||
function lateInfo(event, sessions, now = new Date()) {
|
||||
const active = activeSessions(sessions);
|
||||
const base = { active: false, mode: event.latePricing || 'full', sessionsLeft: active.length, totalSessions: active.length, fixedPrice: event.lateFixedPrice ?? null };
|
||||
if (event.scheduleType !== 'sessions' || !event.lateJoinEnabled || active.length === 0) return base;
|
||||
const t = new Date(now);
|
||||
const sessionsLeft = active.filter(s => new Date(s.startsAt) > t).length;
|
||||
const started = t >= new Date(active[0].startsAt);
|
||||
const priced = base.mode === 'prorata' || (base.mode === 'fixed' && base.fixedPrice !== null);
|
||||
return { ...base, sessionsLeft, active: started && priced };
|
||||
}
|
||||
|
||||
/** A main ticket's price for a late joiner. Never more than the normal price. */
|
||||
function latePrice(price, info) {
|
||||
if (!info || !info.active || !(price > 0)) return price;
|
||||
if (info.mode === 'fixed') return Math.min(price, Math.max(0, Number(info.fixedPrice)));
|
||||
if (info.mode === 'prorata') {
|
||||
if (!info.totalSessions) return price;
|
||||
const rounded = Math.round((price * info.sessionsLeft / info.totalSessions) / 10) * 10;
|
||||
return Math.min(price, Math.max(10, rounded));
|
||||
}
|
||||
return price;
|
||||
}
|
||||
|
||||
/** Whether `attended` of `total` sessions meets a certificate completion rule. */
|
||||
function completionMet(rule, attended, total) {
|
||||
if (total <= 0 || attended <= 0) return false;
|
||||
const type = rule?.type || 'all';
|
||||
const value = Number(rule?.value) || 0;
|
||||
if (type === 'count') return attended >= Math.min(Math.max(1, value), total);
|
||||
if (type === 'percent') return attended / total * 100 >= Math.min(Math.max(1, value), 100) - 1e-9;
|
||||
return attended >= total;
|
||||
}
|
||||
|
||||
/** Cleans the reminder settings from the editor; null = off. */
|
||||
function normalizeReminders(input) {
|
||||
if (!input || input.enabled === false) return null;
|
||||
const offsets = [...new Set((input.offsets || []).map(Number).filter(n => REMINDER_OFFSETS.includes(n)))].sort((a, b) => b - a);
|
||||
const channels = [...new Set((input.channels || []).filter(c => ['email', 'whatsapp'].includes(c)))];
|
||||
if (offsets.length === 0 || channels.length === 0) return null;
|
||||
return { offsets, channels };
|
||||
}
|
||||
|
||||
/** Cleans the certificate settings from the editor. */
|
||||
function normalizeCertificateConfig(input) {
|
||||
const c = input && typeof input === 'object' ? input : {};
|
||||
const ruleType = ['all', 'count', 'percent'].includes(c.rule?.type) ? c.rule.type : 'all';
|
||||
const str = (v, max) => (v ? String(v).trim().slice(0, max) : '');
|
||||
return {
|
||||
template: CERTIFICATE_TEMPLATES.includes(c.template) ? c.template : 'classic',
|
||||
title: str(c.title, 120) || 'Certificate of Completion',
|
||||
body: str(c.body, 1000) || 'This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.',
|
||||
signatureName: str(c.signatureName, 120),
|
||||
signatureRole: str(c.signatureRole, 120),
|
||||
signatureImage: str(c.signatureImage, 500),
|
||||
rule: { type: ruleType, value: ruleType === 'all' ? null : Math.max(1, parseInt(c.rule?.value, 10) || 1) },
|
||||
sendOnIssue: c.sendOnIssue === true,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Saves the editor's session list for an event: updates rows it kept, creates new ones,
|
||||
* deletes rows it dropped (refused when they have check-ins — cancel those instead), and
|
||||
* moves Event.startDate/endDate to match. Run inside a transaction (`tx`).
|
||||
* @returns {Promise<{ sessions, changes: { added, moved, cancelled, restored, removed } }>}
|
||||
*/
|
||||
async function syncEventSessions(tx, eventId, input) {
|
||||
const wanted = normalizeSessionsInput(input);
|
||||
const existing = await tx.eventSession.findMany({ where: { eventId }, include: { _count: { select: { usages: true } } } });
|
||||
const byId = new Map(existing.map(s => [s.id, s]));
|
||||
const changes = { added: 0, moved: 0, cancelled: 0, restored: 0, removed: 0 };
|
||||
|
||||
const keep = new Set(wanted.filter(w => w.id && byId.has(w.id)).map(w => w.id));
|
||||
for (const s of existing) {
|
||||
if (keep.has(s.id)) continue;
|
||||
if (s._count.usages > 0) {
|
||||
throw sessionError(`"${s.label || dayKey(s.startsAt)}" already has check-ins, so it can't be removed. Cancel it instead.`);
|
||||
}
|
||||
await tx.eventSession.delete({ where: { id: s.id } });
|
||||
changes.removed += 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < wanted.length; i++) {
|
||||
const w = wanted[i];
|
||||
const data = { label: w.label, startsAt: w.startsAt, endsAt: w.endsAt, location: w.location, capacity: w.capacity, order: i };
|
||||
const old = w.id ? byId.get(w.id) : null;
|
||||
if (old) {
|
||||
const cancelledAt = w.cancelled ? (old.cancelledAt || new Date()) : null;
|
||||
if (+old.startsAt !== +w.startsAt || +old.endsAt !== +w.endsAt) changes.moved += 1;
|
||||
if (!old.cancelledAt && cancelledAt) changes.cancelled += 1;
|
||||
if (old.cancelledAt && !cancelledAt) changes.restored += 1;
|
||||
await tx.eventSession.update({ where: { id: old.id }, data: { ...data, cancelledAt } });
|
||||
} else {
|
||||
await tx.eventSession.create({ data: { ...data, eventId, cancelledAt: w.cancelled ? new Date() : null } });
|
||||
changes.added += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = await tx.eventSession.findMany({ where: { eventId }, orderBy: { startsAt: 'asc' } });
|
||||
const bounds = scheduleBounds(sessions);
|
||||
await tx.event.update({ where: { id: eventId }, data: { startDate: bounds.startDate, endDate: bounds.endDate } });
|
||||
return { sessions, changes };
|
||||
}
|
||||
|
||||
const SCHEDULE_SELECT = {
|
||||
id: true, endDate: true, registrationDeadline: true, scheduleType: true, attendanceMode: true, checkInWindow: true,
|
||||
lateJoinEnabled: true, lateJoinUntilSessionId: true, latePricing: true, lateFixedPrice: true,
|
||||
sessions: { orderBy: { startsAt: 'asc' } },
|
||||
};
|
||||
|
||||
// Late pricing is looked up while pricing every option line, so keep each event's
|
||||
// schedule for a few seconds rather than querying it per line.
|
||||
const scheduleCache = new Map();
|
||||
const SCHEDULE_TTL_MS = 5000;
|
||||
|
||||
async function eventSchedule(eventId) {
|
||||
const hit = scheduleCache.get(eventId);
|
||||
if (hit && hit.expires > Date.now()) return hit.value;
|
||||
const value = await prisma.event.findUnique({ where: { id: eventId }, select: SCHEDULE_SELECT });
|
||||
scheduleCache.set(eventId, { value, expires: Date.now() + SCHEDULE_TTL_MS });
|
||||
if (scheduleCache.size > 500) scheduleCache.delete(scheduleCache.keys().next().value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function forgetSchedule(eventId) {
|
||||
scheduleCache.delete(eventId);
|
||||
}
|
||||
|
||||
/** Label for a session in messages: its own label, else "Session N" by position. */
|
||||
function sessionLabel(session, sessions) {
|
||||
if (session.label) return session.label;
|
||||
const idx = activeSessions(sessions).findIndex(s => s.id === session.id);
|
||||
return `Session ${idx >= 0 ? idx + 1 : ''}`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which session a scan on a multi-session event counts for. `requestedId` is the session
|
||||
* the scanner picked (optional — defaults to today's). Returns { session, error } with
|
||||
* session null and an error message when check-in isn't possible now.
|
||||
*/
|
||||
function scanSession(event, sessions, requestedId = null, now = new Date()) {
|
||||
const window = event.checkInWindow || 'whole_day';
|
||||
if (requestedId) {
|
||||
const session = (sessions || []).find(s => s.id === requestedId);
|
||||
if (!session) return { session: null, error: 'That session isn\'t part of this event' };
|
||||
if (session.cancelledAt) return { session: null, error: `${sessionLabel(session, sessions)} was cancelled` };
|
||||
if (!sessionAcceptsCheckIn(session, now, window)) {
|
||||
return { session: null, error: `Check-in for ${sessionLabel(session, sessions)} is only open ${window === 'session_only' ? 'from 2 hours before it starts until it ends' : 'on the day of the session'}` };
|
||||
}
|
||||
return { session, error: null };
|
||||
}
|
||||
const session = currentSession(sessions, now, window);
|
||||
if (!session) {
|
||||
const next = activeSessions(sessions).find(s => new Date(s.startsAt) > new Date(now));
|
||||
return { session: null, error: next ? `No session today — the next one is ${sessionLabel(next, sessions)} on ${dayKey(next.startsAt)}` : 'All sessions of this event are over' };
|
||||
}
|
||||
return { session, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* How a ticket shows an event's date. Multi-day events: "5 sessions · 04 Nov – 02 Dec"
|
||||
* (short) or "Wednesday 4 November 2026 (first of 5 sessions — one ticket for all)" (long).
|
||||
* Returns null for single-date events (callers keep their own date format).
|
||||
*/
|
||||
function ticketDateText(event, style = 'short', session = null, sessions = null) {
|
||||
if (event?.scheduleType !== 'sessions') return null;
|
||||
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
|
||||
// A picked-session ticket is for its own session only.
|
||||
if (session) {
|
||||
const label = sessionLabel(session, event.sessions);
|
||||
return style === 'long'
|
||||
? `${fmt(session.startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} (${label})`
|
||||
: `${label} · ${fmt(session.startsAt, { day: '2-digit', month: 'short', year: 'numeric' })}`;
|
||||
}
|
||||
if (sessions && sessions.length > 1 && style === 'long') {
|
||||
const sorted = [...sessions].sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt));
|
||||
return `${sorted.map(s => fmt(s.startsAt, { day: 'numeric', month: 'short' })).join(', ')} (one ticket per session)`;
|
||||
}
|
||||
const active = activeSessions(event.sessions);
|
||||
if (active.length === 0) return null;
|
||||
if (style === 'long') {
|
||||
const first = fmt(active[0].startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
return active.length === 1 ? first : `${first} (first of ${active.length} sessions — one ticket for all)`;
|
||||
}
|
||||
const short = { day: '2-digit', month: 'short' };
|
||||
return active.length === 1
|
||||
? fmt(active[0].startsAt, { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
: `${active.length} sessions · ${fmt(active[0].startsAt, short)} – ${fmt(active[active.length - 1].startsAt, short)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* scanSession for a ticket: a picked-session ticket only checks in on its own session
|
||||
* (whatever the scanner has selected).
|
||||
*/
|
||||
function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null, now = new Date()) {
|
||||
if (!pinnedSessionId) return scanSession(event, sessions, requestedId, now);
|
||||
const pinned = (sessions || []).find(s => s.id === pinnedSessionId);
|
||||
if (!pinned) return { session: null, error: 'This ticket\'s session no longer exists' };
|
||||
const label = sessionLabel(pinned, sessions);
|
||||
if (requestedId && requestedId !== pinnedSessionId) {
|
||||
return { session: null, error: `This ticket is only for ${label} (${dayKey(pinned.startsAt)}), not the selected session` };
|
||||
}
|
||||
const found = scanSession(event, sessions, pinnedSessionId, now);
|
||||
return found.session ? found : { session: null, error: `This ticket is for ${label} (${dayKey(pinned.startsAt)}) — ${found.error.charAt(0).toLowerCase()}${found.error.slice(1)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups an event's tickets into people for the register and certificates: a
|
||||
* picked-session booking has one ticket per session, so its tickets (same registration,
|
||||
* option and variant) form one group. Every other ticket is its own group.
|
||||
* Each ticket needs registrationOption: { registrationId, eventOptionId, variantId, sessionId }.
|
||||
* @returns {{ key: string, tickets: object[], sessionIds: string[]|null }[]} sessionIds null = all sessions
|
||||
*/
|
||||
function groupTickets(tickets) {
|
||||
const groups = new Map();
|
||||
for (const t of tickets) {
|
||||
const ro = t.registrationOption || {};
|
||||
if (!ro.sessionId) {
|
||||
groups.set(t.id, { key: t.id, tickets: [t], sessionIds: null });
|
||||
continue;
|
||||
}
|
||||
const key = `pick:${ro.registrationId}:${ro.eventOptionId}:${ro.variantId || ''}`;
|
||||
const g = groups.get(key) || { key, tickets: [], sessionIds: [] };
|
||||
g.tickets.push(t);
|
||||
g.sessionIds.push(ro.sessionId);
|
||||
groups.set(key, g);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/** Places already checked in for one session, from a ticket's usages. */
|
||||
function redeemedForSession(usages, sessionId) {
|
||||
return (usages || []).filter(u => u.sessionId === sessionId).reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0);
|
||||
}
|
||||
|
||||
/** Applies late pricing to a resolved option price ({ price, tierId }). */
|
||||
async function withLatePrice(option, resolved) {
|
||||
// Picked sessions are priced per session, so late joining doesn't change them.
|
||||
if (!option?.isMainTicket || option.sessionMode === 'pick' || !option.eventId || !(resolved.price > 0)) return resolved;
|
||||
const event = await eventSchedule(option.eventId);
|
||||
if (!event || event.scheduleType !== 'sessions') return resolved;
|
||||
const price = latePrice(resolved.price, lateInfo(event, event.sessions));
|
||||
return price === resolved.price ? resolved : { ...resolved, price };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check-ins per ticket per session for an event: Map ticketId → Map sessionId → qty.
|
||||
* Cancelled sessions are included; callers drop them when counting.
|
||||
*/
|
||||
async function attendanceBySession(eventId) {
|
||||
const usages = await prisma.ticketUsage.findMany({
|
||||
where: { ticket: { eventId }, sessionId: { not: null } },
|
||||
select: { ticketId: true, sessionId: true, quantityRedeemed: true },
|
||||
});
|
||||
const out = new Map();
|
||||
for (const u of usages) {
|
||||
if (!out.has(u.ticketId)) out.set(u.ticketId, new Map());
|
||||
const m = out.get(u.ticketId);
|
||||
m.set(u.sessionId, (m.get(u.sessionId) || 0) + (u.quantityRedeemed || 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError,
|
||||
dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession,
|
||||
sessionLabel, scanSession, ticketScanSession, redeemedForSession, ticketDateText, groupTickets,
|
||||
registrationClosesAt, wholeCourseClosesAt, isPickOption, expandPickLines, sessionBookings, assertSessionCapacity,
|
||||
lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
|
||||
syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -3,18 +3,20 @@
|
||||
*
|
||||
* Taken = places on registrations that aren't cancelled + places held for a live
|
||||
* waitlist offer (until it expires). An option's stock limit covers all its variants;
|
||||
* a variant can also have its own limit. 0 means unlimited.
|
||||
* a variant can also have its own limit. 0 means unlimited. For a "pick sessions"
|
||||
* option the limit is per session (pass sessionId).
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
/**
|
||||
* @param {{ eventOptionId: string, variantId?: string|null }} line - variantId given → that variant only
|
||||
* @param {{ eventOptionId: string, variantId?: string|null, sessionId?: string|null }} line -
|
||||
* variantId given → that variant only; sessionId given → that session's picked places only
|
||||
* @param {{ excludeEntryId?: string, db?: object }} [opts] - excludeEntryId: ignore this
|
||||
* waitlist offer's hold (the person claiming it)
|
||||
*/
|
||||
async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId = null, db = prisma } = {}) {
|
||||
const scope = variantId ? { variantId } : { eventOptionId };
|
||||
async function takenCount({ eventOptionId, variantId = null, sessionId = null }, { excludeEntryId = null, db = prisma } = {}) {
|
||||
const scope = { ...(variantId ? { variantId } : { eventOptionId }), ...(sessionId ? { sessionId } : {}) };
|
||||
const [registered, held] = await Promise.all([
|
||||
db.registrationOption.aggregate({
|
||||
where: { ...scope, registration: { status: { not: 'cancelled' } } },
|
||||
|
||||
@@ -48,11 +48,11 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
}
|
||||
} catch (_) { /* form models unavailable — don't block */ }
|
||||
|
||||
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId) ──
|
||||
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId, session) ──
|
||||
// Key on both fields so that different variants of the same option are never merged
|
||||
const byOption = new Map();
|
||||
for (const ro of registration.registrationOptions) {
|
||||
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
|
||||
const key = `${ro.eventOptionId}::${ro.variantId || ''}::${ro.sessionId || ''}`;
|
||||
if (!byOption.has(key)) byOption.set(key, []);
|
||||
byOption.get(key).push(ro);
|
||||
}
|
||||
|
||||
@@ -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,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,54 @@
|
||||
const { JOBS, isEnabled, cronFor, nextRun, isNoop } = require('../src/jobs');
|
||||
|
||||
const job = (name) => JOBS.find(j => j.name === name);
|
||||
|
||||
describe('job registry', () => {
|
||||
const saved = { ...process.env };
|
||||
afterEach(() => { process.env = { ...saved }; });
|
||||
|
||||
test('every job has a valid cron schedule and a unique name', () => {
|
||||
const names = new Set();
|
||||
for (const j of JOBS) {
|
||||
expect(names.has(j.name)).toBe(false);
|
||||
names.add(j.name);
|
||||
expect(() => nextRun(j.cron)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('a job is on unless its own flag or JOBS_ENABLED is false', () => {
|
||||
delete process.env.WAITLIST_JOB_ENABLED;
|
||||
delete process.env.JOBS_ENABLED;
|
||||
expect(isEnabled(job('waitlist'))).toBe(true);
|
||||
process.env.WAITLIST_JOB_ENABLED = 'false';
|
||||
expect(isEnabled(job('waitlist'))).toBe(false);
|
||||
process.env.WAITLIST_JOB_ENABLED = 'true';
|
||||
process.env.JOBS_ENABLED = 'false';
|
||||
expect(isEnabled(job('waitlist'))).toBe(false);
|
||||
expect(isEnabled(job('temp-cleanup'))).toBe(false);
|
||||
});
|
||||
|
||||
test('JOB_SCHEDULE_<NAME> overrides the schedule; an invalid one is ignored', () => {
|
||||
process.env.JOB_SCHEDULE_ANALYTICS_ROLLUP = '30 5 * * *';
|
||||
expect(cronFor(job('analytics-rollup'))).toBe('30 5 * * *');
|
||||
process.env.JOB_SCHEDULE_ANALYTICS_ROLLUP = 'every tuesday';
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(cronFor(job('analytics-rollup'))).toBe(job('analytics-rollup').cron);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('daily jobs fire at local (Johannesburg) time', () => {
|
||||
// 07:00 SAST = 05:00 UTC
|
||||
const next = nextRun('0 7 * * *', new Date('2026-11-04T06:00:00Z'));
|
||||
expect(next.toISOString()).toBe('2026-11-05T05:00:00.000Z');
|
||||
expect(nextRun('0 7 * * *', new Date('2026-11-04T04:00:00Z')).toISOString()).toBe('2026-11-04T05:00:00.000Z');
|
||||
});
|
||||
|
||||
test('isNoop: quiet when nothing happened', () => {
|
||||
expect(isNoop({ expired: 0, offers: 0 })).toBe(true);
|
||||
expect(isNoop({ reminders: { sessions: 0, sent: 0 }, certificates: { issued: 0, sent: 0 } })).toBe(true);
|
||||
expect(isNoop({ deleted: 0, errors: 0 })).toBe(true);
|
||||
expect(isNoop(null)).toBe(true);
|
||||
expect(isNoop({ sent: 3 })).toBe(false);
|
||||
expect(isNoop({ file: 'backup.sql.gz' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
const {
|
||||
dayKey,
|
||||
activeSessions,
|
||||
scheduleBounds,
|
||||
normalizeSessionsInput,
|
||||
sessionAcceptsCheckIn,
|
||||
currentSession,
|
||||
registrationClosesAt,
|
||||
lateInfo,
|
||||
latePrice,
|
||||
completionMet,
|
||||
normalizeReminders,
|
||||
normalizeCertificateConfig,
|
||||
scanSession,
|
||||
redeemedForSession,
|
||||
sessionLabel,
|
||||
isPickOption,
|
||||
expandPickLines,
|
||||
ticketScanSession,
|
||||
groupTickets,
|
||||
wholeCourseClosesAt,
|
||||
} = require('../src/utils/sessions');
|
||||
|
||||
// Africa/Johannesburg is UTC+2 all year, so 16:30Z is 18:30 local.
|
||||
const wed = (day, h = 16, m = 30) => new Date(Date.UTC(2026, 10, day, h, m)); // November 2026
|
||||
const session = (id, day, extra = {}) => ({ id, startsAt: wed(day), endsAt: wed(day, 18, 30), cancelledAt: null, ...extra });
|
||||
// Five Wednesdays: 4, 11, 18, 25 Nov and 2 Dec (as day 32 → Date.UTC rolls over).
|
||||
const COURSE = [session('s1', 4), session('s2', 11), session('s3', 18), session('s4', 25), session('s5', 32)];
|
||||
|
||||
describe('dayKey', () => {
|
||||
test('uses the church timezone, not UTC', () => {
|
||||
expect(dayKey(new Date('2026-11-04T23:30:00Z'))).toBe('2026-11-05'); // 01:30 local next day
|
||||
expect(dayKey(new Date('2026-11-04T21:59:00Z'))).toBe('2026-11-04');
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeSessions / scheduleBounds', () => {
|
||||
test('drops cancelled sessions and sorts by start', () => {
|
||||
const list = [session('b', 11), session('a', 4), session('c', 18, { cancelledAt: new Date() })];
|
||||
expect(activeSessions(list).map(s => s.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('bounds run from the first start to the last end', () => {
|
||||
const b = scheduleBounds(COURSE);
|
||||
expect(b.startDate).toEqual(wed(4));
|
||||
expect(b.endDate).toEqual(wed(32, 18, 30));
|
||||
});
|
||||
|
||||
test('a cancelled last session moves the end date back', () => {
|
||||
const list = [...COURSE.slice(0, 4), { ...COURSE[4], cancelledAt: new Date() }];
|
||||
expect(scheduleBounds(list).endDate).toEqual(wed(25, 18, 30));
|
||||
});
|
||||
|
||||
test('no active sessions → null', () => {
|
||||
expect(scheduleBounds([session('a', 4, { cancelledAt: new Date() })])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeSessionsInput', () => {
|
||||
test('sorts sessions and trims labels', () => {
|
||||
const out = normalizeSessionsInput([
|
||||
{ startsAt: wed(11), endsAt: wed(11, 18), label: ' Week 2 ' },
|
||||
{ startsAt: wed(4), endsAt: wed(4, 18) },
|
||||
]);
|
||||
expect(out.map(s => s.label)).toEqual([null, 'Week 2']);
|
||||
});
|
||||
|
||||
test('rejects a session that ends before it starts', () => {
|
||||
expect(() => normalizeSessionsInput([{ startsAt: wed(4, 18), endsAt: wed(4, 16) }])).toThrow(/must end after/);
|
||||
});
|
||||
|
||||
test('rejects missing dates', () => {
|
||||
expect(() => normalizeSessionsInput([{ startsAt: 'nope', endsAt: wed(4) }])).toThrow(/start and end/);
|
||||
});
|
||||
|
||||
test('needs at least one session that is not cancelled', () => {
|
||||
expect(() => normalizeSessionsInput([{ startsAt: wed(4), endsAt: wed(4, 18), cancelled: true }])).toThrow(/at least one/);
|
||||
expect(() => normalizeSessionsInput([])).toThrow(/at least one/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check-in window', () => {
|
||||
const s = session('s1', 4); // 18:30–20:30 local on 4 Nov
|
||||
|
||||
test('whole day accepts any time on the session date (local)', () => {
|
||||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T05:00:00Z'), 'whole_day')).toBe(true); // 07:00 local
|
||||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T21:30:00Z'), 'whole_day')).toBe(true); // 23:30 local
|
||||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T22:30:00Z'), 'whole_day')).toBe(false); // 00:30 next day
|
||||
});
|
||||
|
||||
test('session only accepts from 2 h before start until the end', () => {
|
||||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T14:29:00Z'), 'session_only')).toBe(false);
|
||||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T14:30:00Z'), 'session_only')).toBe(true);
|
||||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T18:30:00Z'), 'session_only')).toBe(true);
|
||||
expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T18:31:00Z'), 'session_only')).toBe(false);
|
||||
});
|
||||
|
||||
test('cancelled sessions never accept check-ins', () => {
|
||||
expect(sessionAcceptsCheckIn({ ...s, cancelledAt: new Date() }, wed(4), 'whole_day')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentSession', () => {
|
||||
test('finds today\'s session', () => {
|
||||
expect(currentSession(COURSE, new Date('2026-11-11T08:00:00Z')).id).toBe('s2');
|
||||
});
|
||||
|
||||
test('null on a day without a session', () => {
|
||||
expect(currentSession(COURSE, new Date('2026-11-12T08:00:00Z'))).toBeNull();
|
||||
});
|
||||
|
||||
test('with two sessions in a day, picks the running one, else the next, else the last', () => {
|
||||
const day = [
|
||||
{ id: 'am', startsAt: new Date('2026-11-07T07:00:00Z'), endsAt: new Date('2026-11-07T10:00:00Z'), cancelledAt: null },
|
||||
{ id: 'pm', startsAt: new Date('2026-11-07T14:00:00Z'), endsAt: new Date('2026-11-07T17:00:00Z'), cancelledAt: null },
|
||||
];
|
||||
expect(currentSession(day, new Date('2026-11-07T08:00:00Z')).id).toBe('am');
|
||||
expect(currentSession(day, new Date('2026-11-07T11:00:00Z')).id).toBe('pm'); // between: next one
|
||||
expect(currentSession(day, new Date('2026-11-07T13:00:00Z')).id).toBe('pm'); // within 2 h of pm
|
||||
expect(currentSession(day, new Date('2026-11-07T19:00:00Z')).id).toBe('pm'); // after both: last
|
||||
expect(currentSession(day, new Date('2026-11-07T04:00:00Z')).id).toBe('am'); // before both: next
|
||||
});
|
||||
});
|
||||
|
||||
describe('registrationClosesAt', () => {
|
||||
const base = { scheduleType: 'sessions', endDate: wed(32, 18, 30), registrationDeadline: null, lateJoinEnabled: false, lateJoinUntilSessionId: null };
|
||||
|
||||
test('single-date events close at the end date', () => {
|
||||
expect(registrationClosesAt({ ...base, scheduleType: 'single' }, [])).toEqual(wed(32, 18, 30));
|
||||
});
|
||||
|
||||
test('without late joining, closes when the first session starts', () => {
|
||||
expect(registrationClosesAt(base, COURSE)).toEqual(wed(4));
|
||||
});
|
||||
|
||||
test('with late joining, closes when the chosen session starts', () => {
|
||||
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3' }, COURSE)).toEqual(wed(18));
|
||||
});
|
||||
|
||||
test('an earlier registration deadline wins', () => {
|
||||
const deadline = new Date('2026-11-01T00:00:00Z');
|
||||
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3', registrationDeadline: deadline }, COURSE)).toEqual(deadline);
|
||||
});
|
||||
|
||||
test('a cancelled "until" session falls back to the first session', () => {
|
||||
const list = COURSE.map(s => (s.id === 's3' ? { ...s, cancelledAt: new Date() } : s));
|
||||
expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3' }, list)).toEqual(wed(4));
|
||||
});
|
||||
});
|
||||
|
||||
describe('late pricing', () => {
|
||||
const event = { scheduleType: 'sessions', lateJoinEnabled: true, latePricing: 'prorata', lateFixedPrice: null };
|
||||
|
||||
test('not active before the first session starts', () => {
|
||||
expect(lateInfo(event, COURSE, wed(3)).active).toBe(false);
|
||||
});
|
||||
|
||||
test('pro-rata on sessions not yet started, rounded to the nearest R10', () => {
|
||||
const info = lateInfo(event, COURSE, wed(5)); // session 1 done → 4 of 5 left
|
||||
expect(info).toMatchObject({ active: true, sessionsLeft: 4, totalSessions: 5 });
|
||||
expect(latePrice(300, info)).toBe(240);
|
||||
expect(latePrice(229, info)).toBe(180); // 183.2 → 180
|
||||
expect(latePrice(232, info)).toBe(190); // 185.6 → 190
|
||||
});
|
||||
|
||||
test('pro-rata never goes below R10 or above the normal price', () => {
|
||||
const info = lateInfo(event, COURSE, wed(26)); // 1 of 5 left
|
||||
expect(latePrice(15, info)).toBe(10); // 3 → 0 → at least R10
|
||||
expect(latePrice(8, info)).toBe(8); // under R10 already: unchanged
|
||||
expect(latePrice(0, info)).toBe(0);
|
||||
});
|
||||
|
||||
test('a session in progress counts as started', () => {
|
||||
const info = lateInfo(event, COURSE, new Date(wed(11).getTime() + 60_000));
|
||||
expect(info.sessionsLeft).toBe(3);
|
||||
});
|
||||
|
||||
test('fixed late price only ever lowers the price', () => {
|
||||
const info = lateInfo({ ...event, latePricing: 'fixed', lateFixedPrice: 150 }, COURSE, wed(12));
|
||||
expect(latePrice(300, info)).toBe(150);
|
||||
expect(latePrice(100, info)).toBe(100);
|
||||
});
|
||||
|
||||
test('full price, late joining off, or a fixed price without an amount → no change', () => {
|
||||
expect(lateInfo({ ...event, latePricing: 'full' }, COURSE, wed(12)).active).toBe(false);
|
||||
expect(lateInfo({ ...event, lateJoinEnabled: false }, COURSE, wed(12)).active).toBe(false);
|
||||
expect(lateInfo({ ...event, latePricing: 'fixed', lateFixedPrice: null }, COURSE, wed(12)).active).toBe(false);
|
||||
expect(latePrice(300, { active: false })).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completionMet', () => {
|
||||
test('all sessions (default)', () => {
|
||||
expect(completionMet(undefined, 5, 5)).toBe(true);
|
||||
expect(completionMet({ type: 'all' }, 4, 5)).toBe(false);
|
||||
});
|
||||
|
||||
test('at least N sessions, capped at the total', () => {
|
||||
expect(completionMet({ type: 'count', value: 4 }, 4, 5)).toBe(true);
|
||||
expect(completionMet({ type: 'count', value: 4 }, 3, 5)).toBe(false);
|
||||
expect(completionMet({ type: 'count', value: 10 }, 3, 3)).toBe(true);
|
||||
});
|
||||
|
||||
test('at least N percent', () => {
|
||||
expect(completionMet({ type: 'percent', value: 80 }, 4, 5)).toBe(true);
|
||||
expect(completionMet({ type: 'percent', value: 80 }, 3, 5)).toBe(false);
|
||||
expect(completionMet({ type: 'percent', value: 66 }, 2, 3)).toBe(true);
|
||||
});
|
||||
|
||||
test('nobody completes without attending', () => {
|
||||
expect(completionMet({ type: 'count', value: 1 }, 0, 5)).toBe(false);
|
||||
expect(completionMet(undefined, 0, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings cleaning', () => {
|
||||
test('reminders: off unless an offset and a channel are chosen', () => {
|
||||
expect(normalizeReminders(null)).toBeNull();
|
||||
expect(normalizeReminders({ offsets: [180], channels: [] })).toBeNull();
|
||||
expect(normalizeReminders({ offsets: [180, 999, 1440], channels: ['email', 'sms'] })).toEqual({ offsets: [1440, 180], channels: ['email'] });
|
||||
});
|
||||
|
||||
test('certificates: unknown template falls back to classic, rule is tidied', () => {
|
||||
const c = normalizeCertificateConfig({ template: 'fancy', rule: { type: 'count', value: '3' } });
|
||||
expect(c.template).toBe('classic');
|
||||
expect(c.rule).toEqual({ type: 'count', value: 3 });
|
||||
expect(c.title).toBe('Certificate of Completion');
|
||||
expect(normalizeCertificateConfig({ rule: { type: 'all', value: 4 } }).rule).toEqual({ type: 'all', value: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanSession', () => {
|
||||
const event = { checkInWindow: 'whole_day' };
|
||||
|
||||
test("defaults to today's session", () => {
|
||||
expect(scanSession(event, COURSE, null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
|
||||
});
|
||||
|
||||
test('no session today: says when the next one is', () => {
|
||||
const r = scanSession(event, COURSE, null, new Date('2026-11-12T09:00:00Z'));
|
||||
expect(r.session).toBeNull();
|
||||
expect(r.error).toMatch(/next one is Session 3 on 2026-11-18/);
|
||||
});
|
||||
|
||||
test('after the last session: all over', () => {
|
||||
expect(scanSession(event, COURSE, null, new Date('2026-12-10T09:00:00Z')).error).toMatch(/over/);
|
||||
});
|
||||
|
||||
test('a picked session must be open now', () => {
|
||||
expect(scanSession(event, COURSE, 's3', new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
|
||||
expect(scanSession(event, COURSE, 's4', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only open on the day/);
|
||||
expect(scanSession({ checkInWindow: 'session_only' }, COURSE, 's3', new Date('2026-11-18T09:00:00Z')).error).toMatch(/2 hours before/);
|
||||
});
|
||||
|
||||
test('a cancelled or unknown session is refused', () => {
|
||||
const list = COURSE.map(s => (s.id === 's3' ? { ...s, cancelledAt: new Date() } : s));
|
||||
expect(scanSession(event, list, 's3', new Date('2026-11-18T09:00:00Z')).error).toMatch(/cancelled/);
|
||||
expect(scanSession(event, COURSE, 'nope').error).toMatch(/isn't part/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redeemedForSession / sessionLabel', () => {
|
||||
test("counts only that session's check-ins", () => {
|
||||
const usages = [{ sessionId: 's1', quantityRedeemed: 2 }, { sessionId: 's2', quantityRedeemed: 1 }, { sessionId: 's1' }];
|
||||
expect(redeemedForSession(usages, 's1')).toBe(3);
|
||||
expect(redeemedForSession(usages, 's3')).toBe(0);
|
||||
});
|
||||
|
||||
test('uses the label, else the position among active sessions', () => {
|
||||
expect(sessionLabel({ id: 'x', label: 'Opening night' }, COURSE)).toBe('Opening night');
|
||||
expect(sessionLabel(COURSE[2], COURSE)).toBe('Session 3');
|
||||
const list = [{ ...COURSE[0], cancelledAt: new Date() }, ...COURSE.slice(1)];
|
||||
expect(sessionLabel(list[2], list)).toBe('Session 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pick sessions', () => {
|
||||
const pickEvent = {
|
||||
scheduleType: 'sessions', attendanceMode: 'pick', endDate: wed(32, 18, 30), registrationDeadline: null,
|
||||
lateJoinEnabled: false, sessions: COURSE,
|
||||
eventOptions: [{ id: 'drop', name: 'Drop-in', sessionMode: 'pick' }, { id: 'full', name: 'Full course', sessionMode: 'all' }],
|
||||
};
|
||||
|
||||
test('isPickOption needs a pick-mode multi-day event and a pick option', () => {
|
||||
expect(isPickOption(pickEvent, pickEvent.eventOptions[0])).toBe(true);
|
||||
expect(isPickOption(pickEvent, pickEvent.eventOptions[1])).toBe(false);
|
||||
expect(isPickOption({ ...pickEvent, attendanceMode: 'whole' }, pickEvent.eventOptions[0])).toBe(false);
|
||||
});
|
||||
|
||||
test('a pick line becomes one line per session; others stay as they are', () => {
|
||||
const out = expandPickLines(pickEvent, [
|
||||
{ eventOptionId: 'drop', quantity: 2, sessionIds: ['s3', 's4', 's3'] },
|
||||
{ eventOptionId: 'full', quantity: 1 },
|
||||
], { now: wed(5) });
|
||||
expect(out).toEqual([
|
||||
{ eventOptionId: 'drop', quantity: 2, sessionId: 's3' },
|
||||
{ eventOptionId: 'drop', quantity: 2, sessionId: 's4' },
|
||||
{ eventOptionId: 'full', quantity: 1, sessionId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('sessions must be chosen, upcoming, and not on whole-course tickets', () => {
|
||||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1 }])).toThrow(/Choose the sessions/);
|
||||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
|
||||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s3'] }])).toThrow(/covers every session/);
|
||||
expect(() => expandPickLines({ ...pickEvent, requiresParentalConsent: true }, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s3'] }], { now: wed(5) })).toThrow(/youth/);
|
||||
});
|
||||
|
||||
test('staff can still book a session that is in progress', () => {
|
||||
const during = new Date(wed(4).getTime() + 30 * 60000);
|
||||
expect(expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: during, staff: true })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('registration stays open until the last session; whole-course tickets close at the first', () => {
|
||||
expect(registrationClosesAt(pickEvent, COURSE)).toEqual(wed(32));
|
||||
expect(wholeCourseClosesAt(pickEvent, COURSE)).toEqual(wed(4));
|
||||
});
|
||||
|
||||
test('a picked ticket only checks in on its own session', () => {
|
||||
const event = { checkInWindow: 'whole_day' };
|
||||
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
|
||||
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-11T09:00:00Z')).error).toMatch(/This ticket is for Session 3 \(2026-11-18\)/);
|
||||
expect(ticketScanSession(event, COURSE, 's3', 's2', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only for Session 3/);
|
||||
expect(ticketScanSession(event, COURSE, null, null, new Date('2026-11-11T09:00:00Z')).session.id).toBe('s2');
|
||||
});
|
||||
|
||||
test('groupTickets puts one booking\'s picked tickets together', () => {
|
||||
const t = (id, sessionId, registrationId = 'r1', eventOptionId = 'drop') => ({ id, registrationOption: { registrationId, eventOptionId, variantId: null, sessionId } });
|
||||
const groups = groupTickets([t('a', 's1'), t('b', null, 'r1', 'full'), t('c', 's2'), t('d', 's1', 'r2')]);
|
||||
expect(groups.map(g => g.tickets.map(x => x.id))).toEqual([['a', 'c'], ['b'], ['d']]);
|
||||
expect(groups[0].sessionIds).toEqual(['s1', 's2']);
|
||||
expect(groups[1].sessionIds).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
# Hope Events — New Features
|
||||
|
||||
Planned features for the next round of development. Each section covers what
|
||||
the feature does, who uses it, how it fits the existing platform, and the edge
|
||||
cases to handle. See [FEATURES.md](./FEATURES.md) for what already exists.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Coupon codes](#1-coupon-codes)
|
||||
2. [Waitlist](#2-waitlist)
|
||||
3. [Group tickets](#3-group-tickets)
|
||||
4. [Offline scanning](#4-offline-scanning)
|
||||
5. [Parental consent (youth events)](#5-parental-consent-youth-events)
|
||||
6. [Sponsor a spot](#6-sponsor-a-spot)
|
||||
7. [Ticket transfer](#7-ticket-transfer)
|
||||
8. [Post-event feedback form](#8-post-event-feedback-form)
|
||||
9. [Group / room allocation](#9-group--room-allocation)
|
||||
10. [Registration funnel stats](#10-registration-funnel-stats)
|
||||
11. [2FA for Admins and Supervisors](#11-2fa-for-admins-and-supervisors)
|
||||
12. [Admin "impersonate user"](#12-admin-impersonate-user)
|
||||
13. [Suggested build order](#suggested-build-order)
|
||||
|
||||
---
|
||||
|
||||
## 1. Coupon codes
|
||||
|
||||
Discount codes applied at registration. The main use case is **staff
|
||||
registering for events** at a reduced or free rate, but codes also work for
|
||||
promos, leaders, and volunteers.
|
||||
|
||||
**Who:** Supervisors create and manage codes. Users enter them at checkout.
|
||||
Supervisors can also apply them in Manual registration, At the door and the
|
||||
kiosk.
|
||||
|
||||
### Code settings
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Code | Unique text, case-insensitive (e.g. `STAFF50`) |
|
||||
| Discount type | Percentage · Fixed amount · Comp (100% free) |
|
||||
| Scope | Whole event · Specific ticket option(s) · Specific variant(s) |
|
||||
| Events | One event, several events, or all events |
|
||||
| Valid from / until | Optional date window |
|
||||
| Max total uses | Optional |
|
||||
| Max uses per user | Optional (default 1) |
|
||||
| Min quantity | Optional (e.g. 4+ tickets) |
|
||||
| Restricted to | Anyone · Specific users · Roles (e.g. Staff and above) · Email domain |
|
||||
| Stack with early-bird | Yes (apply on top of early-bird price) / No (best price wins) |
|
||||
| Active | On / off toggle |
|
||||
|
||||
### How it works
|
||||
|
||||
- The user enters a code on the registration page. It is validated server-side
|
||||
and the discount shows as its own line before payment.
|
||||
- The discount is stored as a **separate discount line on the registration**,
|
||||
with its value **locked at apply time**, the same way early-bird tranches
|
||||
are locked. Pricing refresh never re-prices an applied coupon, even if the
|
||||
code later expires or is disabled.
|
||||
- **Restricted codes** (by user, role or domain) need the user to be logged
|
||||
in. Guest registration can't use them. This stops staff codes leaking in
|
||||
group chats.
|
||||
- **Role-based staff pricing (optional):** a ticket option can also carry a
|
||||
"Staff price" that applies automatically for Staff+ users without a code.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Edit options:** percentage codes recalculate over the new item total.
|
||||
Fixed-amount codes stay fixed. Comp codes cover only in-scope options.
|
||||
- **Cancellation / refund:** a cancelled registration releases its use count.
|
||||
- **Discount can't go below R0.** A comp code on a fully covered registration
|
||||
marks it Paid and generates tickets.
|
||||
- **Concurrency:** check use limits inside a transaction so the last use
|
||||
can't be claimed twice.
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Invoices and receipts show the code and discount line.
|
||||
- **New report: Discounts.** Uses per code, total value given away, per event.
|
||||
- Finance and profit reports subtract discounts from expected revenue.
|
||||
- **Audit log:** code created, edited, disabled, and manually applied by staff.
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
model Coupon {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
type CouponType // PERCENT | FIXED | COMP
|
||||
value Decimal?
|
||||
stackEarlyBird Boolean @default(false)
|
||||
validFrom DateTime?
|
||||
validUntil DateTime?
|
||||
maxUses Int?
|
||||
maxUsesPerUser Int? @default(1)
|
||||
minQuantity Int?
|
||||
restrictRoles Role[]
|
||||
restrictDomain String?
|
||||
active Boolean @default(true)
|
||||
events CouponEvent[]
|
||||
options CouponOption[]
|
||||
allowedUsers CouponUser[]
|
||||
redemptions CouponRedemption[]
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model CouponRedemption {
|
||||
id String @id @default(cuid())
|
||||
couponId String
|
||||
registrationId String
|
||||
userId String
|
||||
discountAmount Decimal // locked at apply time
|
||||
appliedById String? // staff member if applied manually
|
||||
createdAt DateTime @default(now())
|
||||
releasedAt DateTime? // set on cancellation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Waitlist
|
||||
|
||||
When an event or ticket option is sold out, users can join a waitlist instead
|
||||
of hitting a dead end.
|
||||
|
||||
**Who:** Users join. Supervisors view and manage the queue.
|
||||
|
||||
### How it works
|
||||
|
||||
- Sold-out options show a **"Join waitlist"** button instead of "Sold Out".
|
||||
- The user picks an option, variant and quantity. Login is required if the
|
||||
event requires login. Otherwise a guest account is created as usual.
|
||||
- When stock frees up (a cancellation, a refund, or a supervisor raising the
|
||||
stock limit), the **next person in line** gets an email/WhatsApp with a
|
||||
**claim link**.
|
||||
- The claim link **reserves the stock for a set time** (default 24 h, set per
|
||||
event). If it isn't used in time, the offer moves to the next person.
|
||||
- A claimed offer goes through normal registration and payment, with
|
||||
early-bird and coupon rules applied as usual.
|
||||
|
||||
### Supervisor tools
|
||||
|
||||
- A waitlist tab per event shows position, person, option, quantity, joined
|
||||
date and offer status.
|
||||
- Supervisors can manually offer a spot to someone, reorder the queue, or
|
||||
remove an entry.
|
||||
- Supervisors can toggle the waitlist on or off per event.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Reserved-but-unclaimed stock counts as sold until the offer expires, so the
|
||||
public site doesn't oversell.
|
||||
- If an offer is for quantity 3 and only 2 free up, offer the 2 with a note,
|
||||
or skip to the next person who fits. This is configurable per event.
|
||||
- Users can leave the waitlist from their dashboard.
|
||||
- Add the waitlist to the Daily summary (count per event).
|
||||
|
||||
---
|
||||
|
||||
## 3. Group tickets
|
||||
|
||||
One payer registers **several named attendees**, each with their own ticket
|
||||
and their own form response. For example, a parent registers three kids for
|
||||
camp.
|
||||
|
||||
**This is optional.** The payer can still choose to receive all tickets
|
||||
themselves.
|
||||
|
||||
### How it works
|
||||
|
||||
- On the registration page, a toggle reads **"Registering for other people?"**
|
||||
- For each attendee: name, plus optional email/phone, plus the event form if
|
||||
there is one.
|
||||
- **Ticket delivery choice per registration:**
|
||||
- **Send all tickets to me** (default), so the payer gets every ticket.
|
||||
- **Send each person their own ticket**, using their email/WhatsApp. If an
|
||||
attendee has no contact details, their ticket falls back to the payer.
|
||||
- Tickets are **individual, named tickets** rather than a quantity ticket, so
|
||||
each one can be checked in, transferred or allocated to a room/group
|
||||
separately.
|
||||
- The payer stays the account owner for billing, invoices and payments.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Works alongside the existing quantity tickets, since an event can allow
|
||||
either.
|
||||
- If an attendee's email/phone matches an existing account, link the ticket
|
||||
to that account (with the same duplicate-account protection as today).
|
||||
- Attendee forms: each attendee has their own response. The payer can fill
|
||||
them in, or each attendee can get a link to fill in their own.
|
||||
- Feeds into parental consent (one consent request per minor attendee) and
|
||||
group/room allocation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Offline scanning
|
||||
|
||||
Lets the scanner keep working when venue Wi-Fi or mobile data drops.
|
||||
|
||||
> ⚠️ **Rules: offline mode is per section, and only one device can scan a
|
||||
> section offline at a time.**
|
||||
> While offline, devices can't see each other's scans. If two devices scanned
|
||||
> the same section offline, the same ticket could be redeemed twice. Locking
|
||||
> each section to a single device removes that risk without needing conflict
|
||||
> resolution.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Before going offline (while online):** a staff member picks an event and
|
||||
section and taps **"Enable offline mode"**.
|
||||
2. The server **locks that section to this device** and records the device ID
|
||||
and the staff member. Other devices see "Offline-locked by [name] on [device]"
|
||||
and can't scan that section, online or offline, until it's released.
|
||||
3. The device **downloads a snapshot** of every ticket valid for that section
|
||||
(ticket ID, holder name, type, quantity remaining) into IndexedDB.
|
||||
4. **While offline:** scans validate against the local snapshot and update
|
||||
remaining quantities locally. Every scan is queued with a timestamp.
|
||||
5. **When back online:** queued scans sync to the server automatically. Any
|
||||
conflicts (for example, a ticket transferred or cancelled while offline)
|
||||
show in a **sync report** for a supervisor to review.
|
||||
6. Staff tap **"Release section"** to end offline mode and unlock the section.
|
||||
|
||||
### Rules and edge cases
|
||||
|
||||
- One section can only be locked to one device at a time. Other sections of
|
||||
the same event can be locked to other devices.
|
||||
- A supervisor can **force-release** a lock if a phone dies or goes missing.
|
||||
Unsynced scans on that device are lost unless the device reconnects later,
|
||||
in which case they sync and are flagged.
|
||||
- **Snapshot age:** show "Ticket list downloaded at 17:42". Tickets bought
|
||||
after that time won't be on the device, and the scanner shows "Not found in
|
||||
offline list. Check at the door desk."
|
||||
- The snapshot is stored only for the lock's duration and cleared on release.
|
||||
- Check-in confirmation messages to holders are sent after sync, not
|
||||
instantly.
|
||||
- **Audit log:** lock, release, force-release, and sync with scan count.
|
||||
- Built as a **PWA** (service worker caches the scanner page and assets).
|
||||
|
||||
---
|
||||
|
||||
## 5. Parental consent (youth events)
|
||||
|
||||
For youth events, a parent or guardian must give consent before an attendee
|
||||
under 18 can attend. The parent gets an **email and a WhatsApp** with a link
|
||||
to review and give consent.
|
||||
|
||||
### Event setup
|
||||
|
||||
- New event setting: **"Youth event: requires parental consent"**.
|
||||
- Supervisors write the **consent text** (indemnity, activities, photo
|
||||
permission, transport, medical treatment authorisation) in the event wizard.
|
||||
- Optional extra sections on the consent page: **medical info** (allergies,
|
||||
medication, conditions, medical aid), **emergency contact**, and **photo
|
||||
consent** (Yes/No, recorded separately).
|
||||
|
||||
### How it works
|
||||
|
||||
1. During registration, each attendee gives their **date of birth**. If they
|
||||
are under 18, **parent/guardian name, email and phone** are required.
|
||||
2. On registration, the parent gets an **email and a WhatsApp** (always both,
|
||||
regardless of anyone's notification preference) with a unique, secure
|
||||
consent link.
|
||||
3. The consent page shows the event details, attendee name and consent text.
|
||||
The parent fills in any medical/emergency fields, **types their full name**
|
||||
as a signature, ticks "I am the parent/legal guardian", and taps
|
||||
**Give consent** (or **Decline**).
|
||||
4. The system records the timestamp, IP address, device, typed name and the
|
||||
**version of the consent text** they agreed to.
|
||||
5. The registrant and the event's notify recipients get a confirmation.
|
||||
|
||||
### Rules
|
||||
|
||||
- **Tickets are generated but marked "Consent pending"** until consent is
|
||||
given. Scanners show a clear warning and block check-in for that ticket
|
||||
(supervisors can override, with an audit log entry).
|
||||
- **Reminders:** automatic reminder email/WhatsApp after 2 days, then every
|
||||
3 days until the event, or until consent is given or declined.
|
||||
- **Declined consent:** notifies the registrant and supervisors. The
|
||||
registration stays but can't be checked in. A supervisor handles the refund.
|
||||
- **Consent links expire** at the event's end date, and can be resent from
|
||||
the registration detail screen.
|
||||
- If the consent text is edited after some parents have consented, they keep
|
||||
their record of the old version. Supervisors can choose to re-request
|
||||
consent.
|
||||
|
||||
### Supervisor tools
|
||||
|
||||
- **Consent status column** on registrations and At the door (Pending /
|
||||
Given / Declined).
|
||||
- **New reports:** Consent status per event, and **Medical & emergency list**
|
||||
(printable, for camp leaders). Access to the medical list is restricted to
|
||||
Supervisor+ and logged.
|
||||
- Bulk "Resend consent request" to everyone still pending.
|
||||
|
||||
### Privacy
|
||||
|
||||
Under POPIA, processing a child's personal information needs the consent of a
|
||||
competent person (parent/guardian). Medical data is special personal
|
||||
information. Store it encrypted, restrict access, include it in the existing
|
||||
anonymise/delete flows, and delete it a set period after the event.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sponsor a spot
|
||||
|
||||
A public donation flow linked to a specific event, so people can fund a spot
|
||||
for someone who can't afford it (bursaries).
|
||||
|
||||
### How it works
|
||||
|
||||
- A new event setting: **"Allow sponsorships"**. This adds a **"Sponsor a
|
||||
spot"** button on the event page.
|
||||
- Sponsors choose: **one full spot** (the main ticket price), **multiple
|
||||
spots**, or **a custom amount**. Payment goes through Yoco as usual.
|
||||
- A sponsor can be anonymous, or add a name and message.
|
||||
- The money is recorded as a **donation tagged to the event**, and lands in a
|
||||
**sponsorship pool** for that event.
|
||||
- Supervisors assign pool funds to registrations using the existing donation
|
||||
assign/unassign flow, fully or partly.
|
||||
- An optional public progress bar shows "12 of 20 spots sponsored".
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Sponsors get a receipt. They can optionally get a thank-you after the event
|
||||
through Automations.
|
||||
- The Donations breakdown report gains a sponsorship pool column (received,
|
||||
assigned, remaining) per event.
|
||||
- Recipients' identities are never shown to sponsors.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ticket transfer
|
||||
|
||||
Users can hand their ticket to someone else before the event.
|
||||
|
||||
### How it works
|
||||
|
||||
- From My Events, a user taps **Transfer** on a ticket, and enters the new
|
||||
holder's name and email/phone.
|
||||
- The new holder gets the ticket by email/WhatsApp. If they don't have an
|
||||
account, an inactive one is created with an activation link (same as guest
|
||||
registration).
|
||||
- The **old QR code is invalidated** and a new one is issued, so screenshots
|
||||
of the old ticket won't scan.
|
||||
- The original payer stays responsible for the registration (billing doesn't
|
||||
move).
|
||||
|
||||
### Rules
|
||||
|
||||
- Allowed only before the event starts, and only for unredeemed tickets
|
||||
(partially redeemed quantity tickets can't be transferred).
|
||||
- Can be disabled per event. Supervisors can always transfer.
|
||||
- If the event needs a form or parental consent, the new holder must complete
|
||||
their own.
|
||||
- **Audit log:** transfer from, to, by whom and when. Offline scanning sync
|
||||
flags any scan of a transferred ticket's old code.
|
||||
|
||||
---
|
||||
|
||||
## 8. Post-event feedback form
|
||||
|
||||
An automatic survey sent after the event.
|
||||
|
||||
### How it works
|
||||
|
||||
- In the event wizard, a new **Feedback** step reuses the form builder, plus
|
||||
new field types: **Rating (1–5 stars)** and **Multiple choice**.
|
||||
- Supervisors pick a **send time** (e.g. the morning after the event ends).
|
||||
It's sent by the existing scheduled job to everyone who **checked in**, or
|
||||
to all registrants, as chosen.
|
||||
- Responses can be anonymous or named (per event setting).
|
||||
- One automatic reminder after 3 days for anyone who hasn't responded.
|
||||
|
||||
### Reporting
|
||||
|
||||
- **New report: Feedback summary.** Response rate, average rating per
|
||||
question, multiple-choice breakdown (bar charts), and a list of text
|
||||
answers. Exports to PDF/Excel like other reports.
|
||||
- Ratings can be compared across events (e.g. Camp 2025 vs Camp 2026).
|
||||
|
||||
---
|
||||
|
||||
## 9. Group / room allocation
|
||||
|
||||
Assign attendees to cabins, rooms, small groups, teams or buses. Built for
|
||||
camps and lockdowns.
|
||||
|
||||
### How it works
|
||||
|
||||
- Per event, supervisors create **allocation sets**, e.g. "Rooms", "Small
|
||||
groups", "Buses". An event can have several sets.
|
||||
- Each set has **groups** with an optional **capacity**, an optional
|
||||
**gender restriction** and an optional **leader** (a staff user).
|
||||
- **Drag-and-drop** board: unassigned attendees on the left, groups on the
|
||||
right, with capacity counters.
|
||||
- **Auto-allocate:** fill groups evenly, respecting capacity and gender, and
|
||||
optionally keeping registrations together (siblings) or splitting them
|
||||
(friends).
|
||||
- Works per attendee, so it relies on group tickets (named attendees).
|
||||
|
||||
### Outputs
|
||||
|
||||
- Printable lists per group (with leader), and a master list.
|
||||
- Optionally show the group/room on the attendee's ticket and in My Events.
|
||||
- Group leaders (staff) can see their group's list, including medical
|
||||
info if they're allowed to.
|
||||
- Bulk message by group ("Bus 2 leaves at 07:30").
|
||||
|
||||
---
|
||||
|
||||
## 10. Registration funnel stats
|
||||
|
||||
Shows where people drop off between seeing an event and paying.
|
||||
|
||||
### Funnel stages (per event)
|
||||
|
||||
1. **Event page views** (unique visitors)
|
||||
2. **Registration started** (opened the registration page)
|
||||
3. **Registration submitted**
|
||||
4. **Paid** (fully paid or free)
|
||||
|
||||
### How it works
|
||||
|
||||
- Lightweight first-party tracking (no third-party analytics). Log page views
|
||||
and stage events with an anonymous session ID. No personal data until the
|
||||
user logs in or registers.
|
||||
- **New report: Registration funnel.** Count and conversion % at each stage,
|
||||
over a date range, with a daily trend chart.
|
||||
- Optional breakdowns: by traffic source (UTM tags or short link alias, e.g.
|
||||
`/camp-2025` vs direct), by device (mobile/desktop), and by ticket option.
|
||||
- A dashboard tile shows conversion % for active events.
|
||||
|
||||
### Privacy
|
||||
|
||||
Respect cookie consent. Aggregate raw page-view data after 90 days.
|
||||
|
||||
---
|
||||
|
||||
## 11. 2FA for Admins and Supervisors
|
||||
|
||||
Two-factor authentication, **required** for Admin and Supervisor accounts.
|
||||
|
||||
### How it works
|
||||
|
||||
- **Method:** authenticator app (TOTP, e.g. Google Authenticator, Microsoft
|
||||
Authenticator). WhatsApp code can be offered as a backup method since WAWP
|
||||
is already set up.
|
||||
- **Enrolment:** on the next login after being given the Supervisor/Admin
|
||||
role, the user must set up 2FA by scanning a QR code and confirming a code.
|
||||
- **Recovery codes:** 10 one-time codes shown once at setup, downloadable.
|
||||
- **Remember this device** for 30 days (optional, per login).
|
||||
- Optional for Staff and User roles, from Profile & Security.
|
||||
|
||||
### Admin controls
|
||||
|
||||
- Admins can **reset another user's 2FA** (e.g. a lost phone). This revokes
|
||||
their sessions and forces re-enrolment.
|
||||
- Security event log entries: 2FA enabled, disabled, reset, recovery code
|
||||
used, failed code attempts.
|
||||
- Failed 2FA attempts count toward the existing login lockout.
|
||||
- The setup wizard enrols the first admin in 2FA.
|
||||
|
||||
---
|
||||
|
||||
## 12. Admin "impersonate user"
|
||||
|
||||
Lets an Admin see the site exactly as a specific user sees it, to debug
|
||||
problems ("my ticket isn't showing").
|
||||
|
||||
### How it works
|
||||
|
||||
- From Manage users, an Admin taps **Impersonate** on a user and must enter a
|
||||
**reason** (required).
|
||||
- A short-lived session is created (max 30 minutes) that acts as the user.
|
||||
- A **bright banner on every page**: "Viewing as [Name]. [End impersonation]".
|
||||
|
||||
### Safety rules
|
||||
|
||||
- **Read-only by default.** Payments, refunds, cancellations, password/email
|
||||
changes, deleting data, giving consent and sending messages are blocked
|
||||
while impersonating.
|
||||
- Can't impersonate another Admin.
|
||||
- Requires 2FA to be active on the admin's account.
|
||||
- **Audit log:** start, end, reason, and every page/action during the
|
||||
session.
|
||||
- Optional: notify the user that an admin viewed their account (an
|
||||
organisation-level setting).
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Features | Why |
|
||||
|-------|----------|-----|
|
||||
| 1 | 2FA, Coupon codes | Security first. Coupons are self-contained and give an immediate win for staff. |
|
||||
| 2 | Group tickets | Foundation for consent, transfer and room allocation (named attendees). |
|
||||
| 3 | Parental consent, Waitlist | The biggest wins for youth events. |
|
||||
| 4 | Offline scanning, Ticket transfer | Door-day reliability. Transfer must exist before offline sync handles it. |
|
||||
| 5 | Group/room allocation, Sponsor a spot | Camp-specific and bursary support. |
|
||||
| 6 | Feedback form, Funnel stats, Impersonate user | Insights and support tooling. |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
audit log entries, a CHANGELOG entry, and updates to FEATURES.md and
|
||||
SETTINGS.md.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-23, after **1.35.0**. The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
## Done so far (road to 2.0)
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.32.0 | Early-bird ribbon on event cards |
|
||||
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
|
||||
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
|
||||
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
|
||||
|
||||
## Next up
|
||||
|
||||
1. **1.36.0 — payment provider interface.** Move Yoco behind a `PaymentProvider`
|
||||
interface (`createCheckout`, `handleWebhook`, `getTransaction`, `refund`,
|
||||
`listTransactions`) with no behaviour change; generalise `YocoTransaction` to a
|
||||
provider transaction table; Site settings → Payments with an Enabled toggle per
|
||||
provider and display order. Decisions: fees are **never** deducted or passed on
|
||||
(people handle fees at cashup); minimum online payment **R15** for all providers.
|
||||
2. **1.37.0 PayFast → 1.38.0 SnapScan → 1.39.0 Zapper** (one release each). Get the
|
||||
current API documentation links from Joshua before building each one, and don't
|
||||
guess at an API — sandbox/test credentials needed for SnapScan and Zapper.
|
||||
3. **1.40.0–1.41.0 — POPIA toolkit** (spec §3 in the roadmap).
|
||||
4. **1.42.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
`joshua/hope-events-hub`).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
- **Release routine:** branch `feat/<name>` → commit → bump with
|
||||
`npm version X.Y.Z --no-git-tag-version` in `.`, `backend` and `frontend` → add the
|
||||
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
|
||||
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
|
||||
version per feature.
|
||||
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
|
||||
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
|
||||
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
|
||||
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
|
||||
- **Prisma migrations:** `npx prisma migrate dev --create-only --name x` against a local
|
||||
DB. Prisma 7 keeps adding an unrelated `_EventNotifyRecipients` primary-key/index
|
||||
change — delete those two blocks from the generated SQL. It also rewrites the comment
|
||||
in `migration_lock.toml`; `git checkout` that file back. New `AdminAuditAction` values
|
||||
need a migration.
|
||||
- **Server Node version:** the deployment notes install Node 20, so dependencies must
|
||||
support Node 20 (that's why pg-boss is pinned to 10.x).
|
||||
- **Pushing:** pushing from some shells fails with "Failed to authenticate user" (a stale
|
||||
saved credential); pushing from your own terminal works.
|
||||
|
||||
## Tests
|
||||
|
||||
- Backend unit tests: `cd backend && npm test` (Jest).
|
||||
- End-to-end: `npm run test:e2e` from the repo root (first time: `npm install` and
|
||||
`npx playwright install chromium`). They start their own backend (5100) and
|
||||
frontend (3100) against `<your DATABASE_URL database>_e2e`, which is dropped and
|
||||
re-seeded every run (`e2e/support/prepare-db.js`). The DB user must be able to
|
||||
create databases. ~5 minutes in dev mode.
|
||||
Locally, Playwright reuses servers already running on 5100/3100. If the run times out
|
||||
waiting for the frontend (the home page returns 500 until the e2e backend is up), start
|
||||
them yourself — `node e2e/support/prepare-db.js`, then the backend from `backend/`
|
||||
with `PORT=5100`, the e2e `DATABASE_URL`, `JOBS_ENABLED=false`,
|
||||
`TWO_FACTOR_REQUIRED_ROLES=false`, and `npx next dev -p 3100` from `frontend/` with
|
||||
`NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_API_URL=http://localhost:5100` — then run
|
||||
`npx playwright test`. Don't edit frontend files while it runs.
|
||||
- **Careful with ad-hoc scripts against a dev/test database that has SMTP set up:**
|
||||
registrations and check-ins send real email/WhatsApp. Use made-up `@test.local`
|
||||
people only.
|
||||
- CI: `.gitea/workflows/ci.yml` runs everything once a Gitea Actions runner is
|
||||
registered (none yet). Tag deploys need the `DEPLOY_HOST`, `DEPLOY_USER`,
|
||||
`DEPLOY_SSH_KEY` (+ optional `DEPLOY_PATH`) secrets.
|
||||
|
||||
## Where the multi-day code lives
|
||||
|
||||
- `backend/src/utils/sessions.js` — all session rules (dates, check-in window, late
|
||||
pricing, pick sessions, capacity, grouping); unit tests in `backend/tests/sessions.test.js`.
|
||||
- `backend/src/utils/certificates.js`, `sessionReminders.js`,
|
||||
`controllers/attendanceController.js` (register + certificates API).
|
||||
- `backend/src/jobs/index.js` — the background job registry.
|
||||
- Frontend: `src/lib/sessions.ts`, `src/components/events/ScheduleEditor.tsx`,
|
||||
`SessionPicker.tsx`, `SessionSchedule.tsx`, `src/app/dashboard/supervisor/attendance/`.
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- The at-the-door booking pop-up doesn't have the session picker; per-session tickets
|
||||
are booked from Manual registration (the page says so).
|
||||
- The waitlist works per ticket option, not per session.
|
||||
- Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no
|
||||
camera); scans are tested through the API.
|
||||
@@ -0,0 +1,774 @@
|
||||
# Hope Events — Road to 2.0.0
|
||||
|
||||
Planned features and platform work to take Hope Events from 1.32.x to 2.0.0.
|
||||
Each section covers what the feature does, who uses it, how it fits the
|
||||
existing platform, and the edge cases to handle. See [FEATURES.md](../FEATURES.md)
|
||||
for what already exists, and [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md) for the
|
||||
previous round (1.12.0 – 1.23.0). Where to pick up: [HANDOVER.md](./HANDOVER.md).
|
||||
|
||||
**Theme for 2.0:** turn a feature-rich church app into a product that can be
|
||||
sold — trustworthy (POPIA), flexible (multi-day events, several payment
|
||||
providers), and maintainable (tests, CI, typed backend, versioned API).
|
||||
|
||||
**Status (2026-10-23):** all features in this document are approved. Shipped
|
||||
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
|
||||
phase A (sessions, late joining, per-session check-in, attendance register,
|
||||
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
|
||||
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
|
||||
(pick sessions, per-session capacity). **Next: 1.36.0 payment provider interface (Yoco
|
||||
moved behind it), then PayFast, SnapScan, Zapper.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
| Repo | What |
|
||||
|------|------|
|
||||
| `joshua/hope-events` (this repo) | The church app. Everything in this document except the Hub app itself — including the Hub *client* (licence step, "Connect to Hub", heartbeat, licence checks) |
|
||||
| `joshua/hope-events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
|
||||
2. [Payment providers: PayFast, SnapScan, Zapper](#2-payment-providers-payfast-snapscan-zapper)
|
||||
3. [POPIA toolkit](#3-popia-toolkit)
|
||||
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
|
||||
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
|
||||
6. [Event series and cloning](#6-event-series-and-cloning)
|
||||
7. [Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)
|
||||
8. [Outbound webhooks](#8-outbound-webhooks)
|
||||
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
|
||||
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
|
||||
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
|
||||
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
|
||||
13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub)
|
||||
14. [Foundations (platform work)](#14-foundations-platform-work)
|
||||
15. [Suggested build order](#suggested-build-order)
|
||||
16. [Decisions](#decisions-2026-10-22)
|
||||
|
||||
---
|
||||
|
||||
## 1. Multi-day events (sessions)
|
||||
|
||||
One event that runs over several dates, e.g. **"Alpha Course — every Wednesday
|
||||
18:30–20:30 for 5 weeks"**, a 3-day conference, or a camp with an evening
|
||||
session each day. People register and pay **once**, get **one ticket**, and are
|
||||
checked in **per session**.
|
||||
|
||||
> Not the same as [Event series](#6-event-series-and-cloning). A series groups
|
||||
> *separate* events (Camp 2026, Camp 2027) that each have their own
|
||||
> registrations. Sessions are dates *inside one* event.
|
||||
|
||||
**Who:** Supervisors set up the schedule. Users see it on the event page, in
|
||||
their tickets and in their calendar. Staff check in per session.
|
||||
|
||||
### Event setup
|
||||
|
||||
New **Schedule** section on the event form:
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Schedule type | Single date (today's behaviour) · Multiple sessions |
|
||||
| Pattern builder | Repeat **daily / weekly / every N weeks** on chosen weekday(s), start date, time from–to, **number of sessions** or **until date** |
|
||||
| Generated sessions | Editable list: change date/time/location/label of any one session, delete, or add an extra one-off date |
|
||||
| Session label | Optional, e.g. "Week 1 — Is there more to life?" (defaults to "Session 1, 2, …") |
|
||||
| Session location | Optional; falls back to the event location |
|
||||
| Attendance mode | **Whole course** (one registration covers all sessions) · **Pick sessions** (see phase B below) |
|
||||
| Late joining | Per event: Off (registration closes when the first session starts) · Allow until the start of session N |
|
||||
| Late-joiner price | Per event, only when late joining is on: Full price · **Pro-rata** (price × remaining sessions ÷ total sessions, rounded to the **nearest R10**, e.g. R183 → R180, R185 → R190) · Fixed late price |
|
||||
| Check-in window | Per event: **Whole day** (default — any time on the session's date, in case someone was let in without being scanned) · Session time only (2 h before start until the session ends) |
|
||||
| Session reminders | Per event, **off by default**: when on, choose timing (e.g. 3 h before, the day before at 18:00, or both) and channels (email / WhatsApp) |
|
||||
| Certificates | Per event, **off by default**: when on, choose a template and the completion rule (see [Certificates](#certificates)) |
|
||||
|
||||
The pattern builder is only a helper: it generates rows, then the session list
|
||||
is the source of truth. Typing "every Wednesday for 5 weeks" and then skipping
|
||||
a public holiday is just deleting one row.
|
||||
|
||||
### How it works
|
||||
|
||||
- `Event.startDate` / `endDate` **stay** and are kept in sync automatically
|
||||
(first session start, last session end). Everything that already reads them —
|
||||
consent age checks, transfer cut-off, early-bird, feedback timing, ICS,
|
||||
listings, sitemap — keeps working unchanged. Only places that need
|
||||
per-session behaviour are updated.
|
||||
- **Public event page:** shows the full schedule, with past sessions greyed out
|
||||
and a "Next session" highlight. Event cards show "5 sessions · Wed 4 Nov –
|
||||
Wed 2 Dec".
|
||||
- **Event listings:** a multi-session event stays "upcoming / running" until
|
||||
its **last** session ends, and sorts by its **next** session.
|
||||
- **Tickets (PDF, email, WhatsApp):** list all session dates. One QR code.
|
||||
- **Calendar (ICS):** one VEVENT per session (not an RRULE, so edited or
|
||||
skipped sessions are exact).
|
||||
- **Check-in:** the scanner auto-selects today's session (on a day with more
|
||||
than one session, the one happening now or next), with a dropdown to
|
||||
override. Whether a scan is accepted depends on the event's **check-in
|
||||
window** (whole day or session time only). A ticket can be checked in
|
||||
**once per session** (per person for group tickets). Scanning again in the
|
||||
same session shows "Already checked in for Session 3 at 18:42".
|
||||
- **Manual check-in:** supervisors can tick someone as present for a session
|
||||
from the attendance register afterwards (e.g. let in without a scan), logged
|
||||
in the audit log with who marked it.
|
||||
- **Reminders:** off by default; when switched on for the event, a reminder
|
||||
goes out before **each** session (email/WhatsApp, via the scheduled-message
|
||||
worker), e.g. "See you tonight at 18:30".
|
||||
- **Late joiners:** when late joining is on, the registration page shows which
|
||||
sessions are left and the late price ("3 of 5 sessions left — R180").
|
||||
- **Changes:** moving or cancelling a session after people registered prompts
|
||||
the supervisor to notify registrants (pre-filled broadcast), and updates the
|
||||
ICS/tickets.
|
||||
|
||||
### Phase B: pick sessions (drop-in)
|
||||
|
||||
Second step, released separately. Lets people register for **some** sessions,
|
||||
e.g. a conference where each day is sold separately or a weekly drop-in class.
|
||||
|
||||
- Ticket options can be scoped to sessions: "Day pass (pick days)" priced per
|
||||
session, "Full conference pass" covering all.
|
||||
- Optional **capacity per session** on top of the option stock limit.
|
||||
- The ticket stores which sessions it covers; scanning on a session it doesn't
|
||||
cover shows "Not registered for this session" (with "Add & charge" at the
|
||||
door for supervisors).
|
||||
- Waitlist works per session when sessions have their own capacity.
|
||||
|
||||
### Reporting
|
||||
|
||||
- **Attendance register:** grid of attendees × sessions with ticks, totals per
|
||||
session, attendance % per person. Export to Excel and PDF.
|
||||
- **Completion filter:** "attended at least 4 of 5" — useful for certificates
|
||||
and for follow-up ("missed the last 2 weeks").
|
||||
- Daily summary email includes "Sessions today".
|
||||
- Funnel stats unchanged (they are per event).
|
||||
|
||||
### Certificates
|
||||
|
||||
Optional completion certificates (PDF), chosen per event.
|
||||
|
||||
- **3–4 built-in templates**, e.g. *Classic* (border, serif), *Modern* (clean,
|
||||
accent colour bar), *Church* (logo-led, scripture line), *Kids* (playful,
|
||||
for children's programmes). All use the site logo and brand colour from Site
|
||||
settings.
|
||||
- **Event settings:** Certificates on/off · Template · Title ("Certificate of
|
||||
Completion") · Body text with placeholders (`{name}`, `{event}`,
|
||||
`{sessionsAttended}`, `{totalSessions}`, `{endDate}`) · Signature name and
|
||||
role (optional uploaded signature image) · **Completion rule**: attended at
|
||||
least N sessions or N % (default: all sessions).
|
||||
- **Preview** button in the event form renders a sample PDF.
|
||||
- **Who gets one:** attendees meeting the completion rule after the last
|
||||
session. Supervisors can issue/withdraw one manually (audit logged).
|
||||
- **Delivery:** download from My Events, bulk download (ZIP) from the
|
||||
attendance register, and optional email/WhatsApp send.
|
||||
- Each certificate has a short verification code + QR linking to a public
|
||||
"certificate valid" page (name, event, date only).
|
||||
- Generated with `pdfkit`, like tickets and receipts (`utils/pdfDocs.js`).
|
||||
- Also usable on single-date events (completion = checked in).
|
||||
|
||||
### Edge cases
|
||||
|
||||
| Case | Behaviour |
|
||||
|------|-----------|
|
||||
| Delete a session that has check-ins | Blocked; offer **Cancel session** instead (kept for history, hidden from schedule, excluded from attendance %) |
|
||||
| Switch Multiple → Single after registrations | Allowed only if no session check-ins exist |
|
||||
| Ticket transfer | Allowed until the **first** session starts (current rule). Optional later: allow mid-course transfers with check-in history kept on the original holder |
|
||||
| Parental consent | Age is measured at the first session (current `startDate` rule) |
|
||||
| Medical details deletion | Runs after the **last** session (end date), as today |
|
||||
| Feedback form | Sent after the last session |
|
||||
| Cashup | Still per event. At-the-door cash per session is captured with the session on the payment so reports can split it |
|
||||
| Offline scanning | Session list and per-session usages are part of the offline lock bundle; sync keeps the session id per scan |
|
||||
| Timezones | Sessions stored as UTC instants, same as `Event.startDate` today (see `icsUtils.js`) |
|
||||
| Session overlaps / out of order | Allowed (conferences have parallel tracks later), list always sorted by start |
|
||||
| Kiosk / At the door | Shows the current session; check-in counts against it |
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
enum ScheduleType { single sessions }
|
||||
enum AttendanceMode { whole pick }
|
||||
|
||||
enum LatePricing { full prorata fixed }
|
||||
enum CheckInWindow { whole_day session_only }
|
||||
|
||||
model Event {
|
||||
// ...existing fields
|
||||
scheduleType ScheduleType @default(single)
|
||||
attendanceMode AttendanceMode @default(whole)
|
||||
lateJoinEnabled Boolean @default(false)
|
||||
lateJoinUntilSessionId String?
|
||||
latePricing LatePricing @default(full)
|
||||
lateFixedPrice Float?
|
||||
checkInWindow CheckInWindow @default(whole_day)
|
||||
sessionReminders Json? // null = off; { offsets: [...], channels: [...] }
|
||||
certificatesEnabled Boolean @default(false)
|
||||
certificateConfig Json? // template, title, body, signature, completion rule
|
||||
sessions EventSession[]
|
||||
certificates Certificate[]
|
||||
}
|
||||
|
||||
model Certificate {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
attendeeId String?
|
||||
userId String?
|
||||
name String
|
||||
code String @unique // verification code
|
||||
manual Boolean @default(false)
|
||||
issuedAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
model EventSession {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
label String?
|
||||
startsAt DateTime
|
||||
endsAt DateTime
|
||||
location String?
|
||||
capacity Int? // phase B
|
||||
cancelledAt DateTime?
|
||||
order Int @default(0)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
usages TicketUsage[]
|
||||
ticketSessions TicketSession[]
|
||||
|
||||
@@index([eventId, startsAt])
|
||||
}
|
||||
|
||||
model TicketUsage {
|
||||
// ...existing fields
|
||||
sessionId String? // null on single-date events
|
||||
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
// Phase B only: which sessions a "pick sessions" ticket covers.
|
||||
model TicketSession {
|
||||
ticketId String
|
||||
sessionId String
|
||||
@@id([ticketId, sessionId])
|
||||
}
|
||||
```
|
||||
|
||||
`Ticket.isUsed` keeps its meaning for single-date events. For session events
|
||||
it's not used for check-in decisions; "checked in" is per session
|
||||
(`TicketUsage.sessionId`).
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Session CRUD + pattern generator in `backend/src/utils/sessions.js` with
|
||||
Jest tests (DST-free but still test month boundaries, skipped dates, "until"
|
||||
vs "count").
|
||||
- Scan endpoint takes an optional `sessionId`; defaults to the current session.
|
||||
- Help content, audit log entries (session created/moved/cancelled),
|
||||
CHANGELOG, FEATURES.md, SETTINGS.md.
|
||||
|
||||
---
|
||||
|
||||
## 2. Payment providers: PayFast, SnapScan, Zapper
|
||||
|
||||
Today Yoco is the only online provider. Add **PayFast**, **SnapScan** and
|
||||
**Zapper**, and let the admin **enable one or more** in Site settings. At
|
||||
checkout the user picks from the enabled ones.
|
||||
|
||||
**Who:** Admins configure providers. Users choose at payment. Supervisors
|
||||
reconcile.
|
||||
|
||||
### Site settings → Payments
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Providers | Yoco · PayFast · SnapScan · Zapper — each with an **Enabled** toggle |
|
||||
| Credentials | Per provider (merchant id/key, API key, passphrase, webhook secret…), stored encrypted (`utils/encryption.js`, same as WhatsApp) |
|
||||
| Mode | Live / Sandbox per provider (where supported) |
|
||||
| Display order / default | Which one is shown first. Default order: Yoco · PayFast · SnapScan · Zapper |
|
||||
| Minimum amount | **R15 for all providers** (one site-wide minimum for online payments) |
|
||||
|
||||
**Fees are not handled automatically.** Payments are recorded at the full
|
||||
amount paid; provider fees are not deducted, estimated or added to the payer's
|
||||
total. Fees are dealt with by people during **cashup**, as today. There is no
|
||||
"pass fee to payer" option.
|
||||
|
||||
"Test connection" button per provider, like the WhatsApp setup.
|
||||
|
||||
### How it works
|
||||
|
||||
- A single **PaymentProvider interface** in the backend:
|
||||
`createCheckout`, `handleWebhook` (verify signature → mark paid),
|
||||
`getTransaction`, `refund` (if the provider supports it via API),
|
||||
`listTransactions` (for reconciliation).
|
||||
- Yoco is moved behind the same interface first (no behaviour change), then
|
||||
PayFast, SnapScan and Zapper are added one release each.
|
||||
- SnapScan and Zapper are QR/app-based: on desktop show the QR, on mobile
|
||||
deep-link into the app; the page polls/waits for the webhook and moves on
|
||||
automatically when payment lands.
|
||||
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to
|
||||
`ProviderTransaction` (`provider` column) so the existing reconciliation page
|
||||
works for all providers.
|
||||
- Refunds: if the provider has no refund API, the supervisor refunds in the
|
||||
provider's portal and records it here (existing manual refund flow).
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Provider disabled while a checkout is in progress → webhook still accepted
|
||||
and processed.
|
||||
- Duplicate / late webhooks → idempotent on the provider's transaction id.
|
||||
- Webhook never arrives → "Check payment status" button and a periodic
|
||||
reconcile job.
|
||||
- Amount mismatch (user edits amount in app) → payment held as "needs review".
|
||||
- Only enabled providers appear in kiosk / self-service.
|
||||
- Amount below R15 → online payment options hidden, with a message to pay at
|
||||
the door / contact the organiser.
|
||||
- **Before building each one:** get the documentation links from Joshua and
|
||||
confirm current API docs for webhooks, sandbox, refunds and QR deep-link
|
||||
support.
|
||||
|
||||
---
|
||||
|
||||
## 3. POPIA toolkit
|
||||
|
||||
Tools that make the platform **POPIA-ready** out of the box. This is the main
|
||||
selling point for other churches and organisations: "your attendee data is
|
||||
handled properly, and you can prove it."
|
||||
|
||||
> This is tooling to support compliance, not legal advice. Wording and
|
||||
> retention periods should be checked by whoever is the customer's
|
||||
> Information Officer.
|
||||
|
||||
**Who:** Admins (and the appointed Information Officer) manage it. Users get
|
||||
self-service privacy tools.
|
||||
|
||||
### For users (data subjects)
|
||||
|
||||
- **Privacy centre** in My account:
|
||||
- **Download my data** — JSON + readable PDF of profile, registrations,
|
||||
payments, tickets, form answers, consents, messages sent.
|
||||
- **Correct my data** — edit profile; request correction of locked records.
|
||||
- **Delete my account** — creates a deletion request (see below).
|
||||
- **Communication preferences** — email / WhatsApp / marketing opt-in with
|
||||
timestamps (extends the existing notification preferences).
|
||||
- **Unsubscribe link** in every bulk email and an opt-out keyword for bulk
|
||||
WhatsApp. Opted-out users are skipped automatically in broadcasts.
|
||||
- **Privacy notice acceptance** — versioned; users accept the current version
|
||||
at registration, and are asked again when it changes.
|
||||
|
||||
### For admins
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| Information Officer | Name, contact details, shown on privacy notice and contact page |
|
||||
| Privacy notice editor | Versioned text, published version history |
|
||||
| Consent log | Who consented to what, when, which notice version, how (web, kiosk, WhatsApp) |
|
||||
| Data requests inbox | Access, correction and deletion requests with status, due dates and audit trail |
|
||||
| Deletion workflow | Admin approves → personal info is **anonymised**; financial records kept (amounts, dates) but unlinked from the person, as tax law requires them to be kept |
|
||||
| Retention policies | Per data category (inactive accounts, form answers, medical details, analytics, audit log, backups): keep for N months, then anonymise/delete via nightly job. Sensible defaults shipped |
|
||||
| Operator register | List of third parties that process data (Yoco/PayFast/…, WhatsApp provider, email provider, Sentry, hosting) with purpose; feeds the privacy notice |
|
||||
| Access log | Who exported or viewed personal data in bulk (exports, attendee lists, medical details) |
|
||||
| Breach register | Record a security compromise, affected users, actions taken; templates for notifying users and the Information Regulator |
|
||||
| Children's data | Already covered by parental consent; flagged as special/children's info in reports |
|
||||
| POPIA readiness page | Checklist with score: officer set, notice published, retention set, operators listed, unsubscribe working… |
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Deletion of a user with an open registration / upcoming event → request
|
||||
waits until after the event, or the admin cancels and refunds first.
|
||||
- Anonymised users still count in historic reports ("Deleted user").
|
||||
- Backups contain deleted data until they age out — stated in the notice;
|
||||
backup retention is one of the retention policies.
|
||||
- Impersonation and exports must be logged in the access log.
|
||||
|
||||
---
|
||||
|
||||
## 4. Kids check-in with security labels
|
||||
|
||||
A secure check-in/check-out flow for children's ministry and youth events.
|
||||
**Builds on sessions (§1), attendees, parental consent and allocation groups.**
|
||||
|
||||
**Who:** Parents check in children (self-service kiosk or staff desk).
|
||||
Kids-ministry staff run rooms. Supervisors set up rooms.
|
||||
|
||||
### How it works
|
||||
|
||||
- Parent finds their family (phone number / QR from My account) and ticks the
|
||||
children being dropped off. New children can be added on the spot.
|
||||
- The system prints **two labels**:
|
||||
- **Child label:** name, room, allergy/medical icons, short pickup code.
|
||||
- **Parent label:** the same pickup code.
|
||||
- **Check-out** requires scanning or typing the matching code. A mismatch
|
||||
alerts the room leader.
|
||||
- Rooms come from allocation groups (age/grade rules), with **ratios** (e.g.
|
||||
1 leader per 5 toddlers) and live room counts.
|
||||
- **Page a parent:** room leader taps "Call parent" → WhatsApp/SMS to the
|
||||
parent ("Please come to the Toddlers room").
|
||||
- Attendance per child per session feeds the attendance register.
|
||||
|
||||
### Plumbing / edge cases
|
||||
|
||||
- Label printing via browser print with label-size CSS (Dymo / Brother label
|
||||
printers, 62 mm and 89×36 mm presets); PDF fallback.
|
||||
- Allergies and medical info come from the consent/medical fields; respect
|
||||
the medical-info deletion rules.
|
||||
- Custody notes ("Do not release to …") visible to room leaders only.
|
||||
- Works on the existing offline-scanning foundations for flaky Wi-Fi.
|
||||
|
||||
---
|
||||
|
||||
## 5. Volunteer / serving rosters
|
||||
|
||||
Plan who serves where, for an event or each session.
|
||||
|
||||
- **Serving roles** per event/session: Welcome, Parking, Kids, Sound, Kitchen…
|
||||
with the number of people needed.
|
||||
- Volunteers **sign up** for open slots, or supervisors **assign** people.
|
||||
- Reminders via email/WhatsApp before their slot; "can't make it" → swap
|
||||
request to others in the role.
|
||||
- **Volunteer check-in** on the day (separate from attendee check-in).
|
||||
- Volunteers can get a free/discounted ticket automatically (uses coupons/comp).
|
||||
- Reports: roster sheet per session, fill rate, hours served per person.
|
||||
|
||||
---
|
||||
|
||||
## 6. Event series and cloning
|
||||
|
||||
- **Clone event:** copy an event with its options, variants, early-bird tiers,
|
||||
forms, consent text, feedback form, cost template, allocation groups and
|
||||
sessions (dates shifted to the new start). No registrations.
|
||||
- **Series:** group separate events (Camp 2025, Camp 2026; monthly Men's
|
||||
Breakfast) on one series page, with "Next in this series" and a series
|
||||
follow/notify option.
|
||||
- **Year-on-year reports:** registrations, income and attendance compared
|
||||
across events in a series.
|
||||
- **Season pass:** a bundle across events in a series (extends event bundles).
|
||||
|
||||
---
|
||||
|
||||
## 7. Apple and Google Wallet tickets
|
||||
|
||||
- "Add to Apple Wallet" / "Add to Google Wallet" buttons next to the PDF
|
||||
ticket, in email and in My Events.
|
||||
- Pass shows event, date (next session for multi-day), location, holder name,
|
||||
QR code.
|
||||
- Passes **update** when the event/session changes (time, venue, cancelled).
|
||||
- Requires an Apple Developer account (pass signing certificate) and a Google
|
||||
Wallet issuer account; both configured in Site settings and **optional** —
|
||||
hidden when not configured.
|
||||
|
||||
---
|
||||
|
||||
## 8. Outbound webhooks
|
||||
|
||||
Let other systems react to what happens in Hope Events.
|
||||
|
||||
- Admin adds endpoints and picks events: `registration.created`,
|
||||
`registration.paid`, `registration.cancelled`, `payment.refunded`,
|
||||
`ticket.checked_in`, `user.created`, `event.published`…
|
||||
- Payloads signed with HMAC (secret per endpoint), retries with back-off,
|
||||
delivery log with "Resend".
|
||||
- Enables integrations (ChurchSuite, Planning Center, Google Sheets via
|
||||
Zapier/Make) without building each one. Native integrations can follow later.
|
||||
|
||||
---
|
||||
|
||||
## 9. Admin dashboard, command palette, bulk actions
|
||||
|
||||
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
|
||||
far, money in (today/week), pending items (unreconciled payments, consent
|
||||
outstanding, waitlist offers, data requests), alerts.
|
||||
- **Command palette (Ctrl+K):** jump to any event, user, registration,
|
||||
ticket or setting; quick actions ("New event", "Find payment R450").
|
||||
- **Bulk actions** on registration/attendee lists: message, export, mark
|
||||
paid, move option, cancel, allocate.
|
||||
- **Saved filters** per user on list pages.
|
||||
|
||||
---
|
||||
|
||||
## 10. Languages (Afrikaans first)
|
||||
|
||||
- i18n framework on the frontend (e.g. `next-intl`); English + Afrikaans.
|
||||
- User language preference; emails, WhatsApp messages and PDFs sent in the
|
||||
user's language, with per-language templates.
|
||||
- Event content stays as typed by the supervisor (optional translated title /
|
||||
description fields later).
|
||||
|
||||
---
|
||||
|
||||
## 11. Accessibility pass (WCAG 2.2 AA)
|
||||
|
||||
- Audit public pages, registration, payment, My Events and the kiosk.
|
||||
- Keyboard navigation, focus states, labels, colour contrast (light and
|
||||
dark), screen-reader names for icon buttons, error messages linked to
|
||||
fields.
|
||||
- Automated `axe` checks in the Playwright suite so it doesn't regress.
|
||||
|
||||
---
|
||||
|
||||
## 12. Multi-campus (one install per church)
|
||||
|
||||
**Decision: one install per church.** Each customer gets their own install
|
||||
and database (the `/setup` wizard already makes this possible). This gives
|
||||
full data separation — which also helps the POPIA story — and lets each
|
||||
church run on a different server. **True multi-tenant is out of scope.**
|
||||
|
||||
**Multi-campus** is in scope: one church with several campuses inside one
|
||||
install.
|
||||
|
||||
### Setup
|
||||
|
||||
- Site settings → **Campuses**: off by default. When on, admins add campuses
|
||||
(name, address, contact, optional logo/colour accent, default location for
|
||||
events).
|
||||
- Single-campus churches never see campus fields anywhere.
|
||||
|
||||
### How it works
|
||||
|
||||
- **Events** belong to one campus, or to **All campuses** (church-wide
|
||||
conferences, camps).
|
||||
- **Public site:** campus filter on the events list, remembered per visitor;
|
||||
campus shown on event cards and tickets. Optional campus landing pages
|
||||
(`/campus/<slug>`).
|
||||
- **Users** can set a home campus (used as the default filter and for
|
||||
broadcasts). Accounts stay shared across campuses — one login for the
|
||||
church.
|
||||
- **Campus-scoped roles:** a supervisor or staff member can be limited to
|
||||
one or more campuses (sees and manages only those events, registrations,
|
||||
scanning, cashups). Admins and unscoped supervisors see everything.
|
||||
- **Reports, cashup, dashboard, funnel stats:** filter by campus plus a
|
||||
church-wide total.
|
||||
- **Broadcasts:** target "everyone at campus X".
|
||||
- **Kids check-in rooms** and **volunteer roles** are per campus.
|
||||
- **Notification recipients** per campus (e.g. the campus pastor gets that
|
||||
campus's daily summary).
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Turning campuses off with campus data present → hidden, not deleted.
|
||||
- Deleting a campus with events → blocked; archive instead.
|
||||
- Moving an event to another campus → scoped supervisors of the old campus
|
||||
lose access; logged in the audit log.
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
model Campus {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
address String?
|
||||
isActive Boolean @default(true)
|
||||
order Int @default(0)
|
||||
events Event[]
|
||||
}
|
||||
// Event.campusId String? (null = all campuses)
|
||||
// User.homeCampusId String?
|
||||
// UserCampusScope { userId, campusId } — empty = unscoped
|
||||
```
|
||||
|
||||
### Running many installs
|
||||
|
||||
Because every church is a separate install, a few tools make that manageable:
|
||||
|
||||
- **Install script:** fresh server → Node, Postgres, PM2, reverse proxy,
|
||||
`.env`, migrations, then `/setup` in the browser.
|
||||
- **Update script:** pull a tagged version, back up the database, run
|
||||
migrations, build, restart; roll back on failure.
|
||||
- **Version and health:** current version shown in admin and on a `/health`
|
||||
endpoint (version, DB, jobs, last backup), reported to the
|
||||
[Hub](#13-central-management-platform-hope-events-hub).
|
||||
|
||||
---
|
||||
|
||||
## 13. Central management platform (Hope Events Hub)
|
||||
|
||||
A separate app, run by Joshua, to see and manage **every Hope Events
|
||||
install** from one place: who the customers are, which version each runs,
|
||||
whether they're healthy, basic usage stats, and their **licence** status.
|
||||
|
||||
**Who:** Joshua (and any future support staff). Customers never log in to it.
|
||||
|
||||
### Separate app, separate repo, separate server
|
||||
|
||||
- **Its own git repository:** `joshua/hope-events-hub` on the same Gitea
|
||||
server (c-git.crosscode.co.za), with its own semver tags, CHANGELOG and
|
||||
deploys. It is never shipped to churches, so none of its code (customers,
|
||||
licence signing key, other churches' data) ends up on a customer's server.
|
||||
- Same stack (Next.js + Express + Prisma + Postgres) so it's familiar.
|
||||
- **What lives where:**
|
||||
- Hub repo: dashboard, customers, licences, licence **signing** (private
|
||||
key — only ever on the Hub server), `/api/installs/register` and
|
||||
`/api/installs/heartbeat` endpoints, alerts.
|
||||
- Hope Events repo: the Hub **client** — licence step in `/setup`,
|
||||
"Connect to Hub" in Site settings, heartbeat job, licence verification
|
||||
with the Hub's **public** key, read-only mode, banners.
|
||||
- **The contract between them** (register / heartbeat payloads, licence token
|
||||
format) is versioned with a `protocolVersion` field and documented in both
|
||||
repos (`docs/hub-protocol.md`). The Hub accepts the current and previous
|
||||
protocol version, so churches on older versions keep reporting while they
|
||||
wait to update.
|
||||
- Runs on its own server/domain (e.g. `hub.<your-domain>`). If the Hub is
|
||||
down, **no church install is affected**.
|
||||
- Hope Family Church's own site is the first registered install.
|
||||
|
||||
### Registration (setup → Hub)
|
||||
|
||||
1. The `/setup` wizard gets a **Licence** step: the customer enters the
|
||||
**licence key** Joshua issued (created in the Hub first).
|
||||
2. When setup completes, the install sends `POST /api/installs/register`
|
||||
with: licence key, site name, URL, admin contact, version, server info
|
||||
(Node/Postgres version, OS), timezone, enabled features.
|
||||
3. The Hub checks the key, links the install to the customer, and returns an
|
||||
**install id + install secret** (stored encrypted in AppSettings) and a
|
||||
**signed licence token**.
|
||||
4. Existing installs (already past setup) get a **"Connect to Hub"** button
|
||||
in Site settings → Licence.
|
||||
|
||||
All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
|
||||
### Heartbeat (install → Hub)
|
||||
|
||||
- Every 15 minutes (job queue) the install POSTs a heartbeat:
|
||||
- **Health:** version, uptime, DB OK, job queue OK, last successful
|
||||
backup, disk free, last error count (from Sentry or local logs), pending
|
||||
migrations.
|
||||
- **Basic stats (aggregate counts only):** users, active/upcoming events,
|
||||
registrations and tickets (30 days / all time), check-ins, WhatsApp/email
|
||||
sent, enabled payment providers, campuses on/off.
|
||||
- **Optional (off by default, customer chooses):** payment totals.
|
||||
- **Install → Hub only.** The install pushes; the Hub never needs to reach
|
||||
into the install, so it works behind firewalls and the Hub has no access
|
||||
to church data.
|
||||
- **No personal information is ever sent** (no names, emails, phones,
|
||||
attendee data). Listed in the install's POPIA operator register and
|
||||
privacy notice as "aggregate usage statistics to the software provider".
|
||||
|
||||
### Hub dashboard
|
||||
|
||||
- **Installs list:** customer, URL, version (flag if behind the latest tag),
|
||||
licence status, last heartbeat (red if > 1 h ago), health status.
|
||||
- **Install detail:** health history, stats charts, version history,
|
||||
licence history, notes, contacts.
|
||||
- **Alerts** (email/WhatsApp to Joshua): install offline > 1 h, backup
|
||||
failed or older than 48 h, disk low, errors spiking, licence expiring.
|
||||
- **Releases:** latest version and changelog; installs see "Update
|
||||
available" in their admin.
|
||||
- **Customers:** contact details, plan, licence(s), billing notes.
|
||||
|
||||
### Licences
|
||||
|
||||
| State | What the install does |
|
||||
|-------|----------------------|
|
||||
| **Active** | Everything works |
|
||||
| **Grace** (expired or Hub unreachable) | Everything works; admins see a banner "Licence needs renewing — X days left". Default 14 days |
|
||||
| **Suspended** | **Read-only:** no new events, no new registrations or payments. Existing tickets **still scan**, and admins can still view and **export all data** |
|
||||
| **Revoked** | Same as suspended, with a permanent banner |
|
||||
|
||||
- Joshua turns a licence on/off, sets expiry dates and plan in the Hub; the
|
||||
install picks up the change on its next heartbeat.
|
||||
- The licence is a **signed token** (Ed25519: the Hub signs with its private
|
||||
key, installs only hold the public key). Installs verify it **offline**, so
|
||||
a Hub outage never switches a church off, and a token can't be faked by
|
||||
editing the database.
|
||||
- **Never a hard kill:** a church must always be able to scan tickets for
|
||||
events people already paid for, and get its own data out (POPIA access
|
||||
rights). Suspension blocks *new* activity only.
|
||||
- Optional plan limits later (e.g. number of campuses, WhatsApp) carried in
|
||||
the token.
|
||||
|
||||
### Things to be aware of
|
||||
|
||||
- Code runs on the customer's server, so a determined developer could patch
|
||||
out the licence check. Real protection is the **contract** plus updates and
|
||||
support only being available to valid licences — the licence check keeps
|
||||
honest customers honest.
|
||||
- The Hub should not offer remote commands (run updates, impersonate,
|
||||
database access) in v1: it's the most valuable target an attacker could
|
||||
hit, so keeping it read-only toward installs keeps every church safe if it
|
||||
is ever compromised. Remote "trigger update" can be revisited later.
|
||||
- The Hub itself needs 2FA, backups and the same audit log approach.
|
||||
|
||||
### Data model (Hub, sketch)
|
||||
|
||||
```prisma
|
||||
model Customer { id, name, contactName, email, phone, notes, licences, installs }
|
||||
model Licence { id, customerId, key, plan, status, expiresAt, graceDays, issuedAt }
|
||||
model Install { id, customerId, licenceId, name, url, secretHash, version,
|
||||
lastHeartbeatAt, health Json, registeredAt }
|
||||
model Heartbeat { id, installId, receivedAt, version, health Json, stats Json }
|
||||
// rolled up to daily after 30 days
|
||||
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Foundations (platform work)
|
||||
|
||||
Not visible to users, but this is what makes 2.0 a safe product to sell and
|
||||
keep adding to.
|
||||
|
||||
| Item | What |
|
||||
|------|------|
|
||||
| CI (Gitea Actions) | Lint, typecheck, Jest, build on every push; deploy on tag |
|
||||
| Playwright E2E | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk |
|
||||
| Job queue | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin |
|
||||
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite |
|
||||
| TypeScript backend | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend |
|
||||
| API v2 | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition |
|
||||
| Dependencies | Express 5, native `fetch` instead of `node-fetch`, password hashing upgrade with rehash-on-login |
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Version | Features | Why |
|
||||
|-------|---------|----------|-----|
|
||||
| 0 | 1.32.0 ✅ | **Early-bird ribbon** on event cards (shipped 2026-10-22) | Quick win |
|
||||
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
|
||||
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 – 1.39.0 | Payment provider interface (Yoco moved behind it) → PayFast → SnapScan → Zapper, one release each | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.40.0 – 1.41.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.42.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.43.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.44.0 – 1.45.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.46.0 – 1.48.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
|
||||
and SETTINGS.md — same as the previous round. Branch → merge → tag → push per
|
||||
feature.
|
||||
|
||||
---
|
||||
|
||||
## Decisions (2026-10-22)
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|----------|----------|
|
||||
| 1 | Pick sessions in the first release? | No — whole-course first (1.33.0), pick sessions as a separate release (1.35.0) |
|
||||
| 2 | Late joining and late price | Set **per event**: late joining on/off (until session N), and the late price (full / pro-rata / fixed) |
|
||||
| 3 | Check-in window | Per event, **whole day** by default (any time on the session's date); "session time only" as the alternative |
|
||||
| 4 | Certificates | Yes — four templates (Classic, Modern, Church, Kids); the event creator chooses whether to issue certificates and which template |
|
||||
| 5 | Session reminders | Off by default, configurable per event |
|
||||
| 6 | Selling model | **One install per church** — data separation, any server. No multi-tenant. **Multi-campus** inside an install is in scope |
|
||||
| — | Pro-rata rounding | Nearest R10 |
|
||||
| — | Multi-campus | In scope (§12), inside one install |
|
||||
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
|
||||
| 7 | Payment provider order | Yoco · PayFast · SnapScan · Zapper. Joshua can supply documentation links |
|
||||
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
|
||||
| — | Minimum online payment | R15 for all providers |
|
||||
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
|
||||
| — | Early-bird ribbon | Added before everything else; shipped as 1.32.0, all later versions moved up by one |
|
||||
|
||||
## Still open
|
||||
|
||||
Hub (needed before phase 6, not before 1.33.0):
|
||||
|
||||
1. Hub domain and server, and where licence keys come from (created by hand
|
||||
in the Hub per customer?).
|
||||
2. Grace period length (proposed 14 days) and suspended = read-only (tickets
|
||||
still scan, data export still works) — agreed?
|
||||
3. Should payment totals be in the heartbeat at all (opt-in per customer), or
|
||||
counts only?
|
||||
4. Licence plans/tiers (e.g. limits on campuses or WhatsApp), or one plan for
|
||||
now?
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type Role = "admin" | "supervisor" | "staff" | "user";
|
||||
export type Seed = {
|
||||
users: Record<Role, { id: string; name: string; email: string; token: string }>;
|
||||
events: { free: string; paid: string; course: string; dropIn: string };
|
||||
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
|
||||
};
|
||||
|
||||
export const API = "http://localhost:5100";
|
||||
|
||||
// Written by e2e/support/prepare-db.js when the e2e backend starts, so read lazily.
|
||||
let cached: Seed | null = null;
|
||||
export function seed(): Seed {
|
||||
if (!cached) cached = JSON.parse(fs.readFileSync(path.join(__dirname, ".data", "seed.json"), "utf8")) as Seed;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Signs the page in as a seeded user (the token goes where the app keeps it). */
|
||||
export async function loginAs(page: Page, role: Role) {
|
||||
await page.addInitScript((token) => {
|
||||
localStorage.setItem("hope_events_token", token);
|
||||
}, seed().users[role].token);
|
||||
}
|
||||
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
// Skip the cookie notice so it never covers what the tests click.
|
||||
await page.addInitScript(() => localStorage.setItem("hope_events_cookie_consent", "declined"));
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
/** Calls the e2e API as a seeded user. */
|
||||
export async function api<T = unknown>(page: Page, role: Role, method: string, url: string, body?: unknown): Promise<T> {
|
||||
const res = await page.request.fetch(`${API}${url}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${seed().users[role].token}`, "Content-Type": "application/json" },
|
||||
data: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok()) throw new Error(`${method} ${url} → ${res.status()}: ${text}`);
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
export { expect };
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
type Registration = { id: string; eventId: string; registrationOptions: { sessionId?: string | null; priceSnapshot: number }[] };
|
||||
|
||||
test.describe("pick sessions", () => {
|
||||
test("book two classes of a drop-in event; a full class can't be ticked", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.dropIn}`);
|
||||
await expect(page.getByText("R50.00 per session").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await expect(picker.getByText("Full")).toBeVisible();
|
||||
await expect(picker.getByRole("checkbox").first()).toBeDisabled();
|
||||
await picker.getByRole("checkbox").nth(1).check();
|
||||
await picker.getByRole("checkbox").nth(2).check();
|
||||
await expect(page.getByText("Total: R100.00")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await page.waitForURL(/\/registration\/success/);
|
||||
|
||||
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
|
||||
const reg = regs.find((r) => r.eventId === seed().events.dropIn);
|
||||
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test, expect, seed } from "./fixtures";
|
||||
|
||||
test.describe("public pages", () => {
|
||||
test("events list shows events, with the multi-day summary", async ({ page }) => {
|
||||
await page.goto("/events");
|
||||
await expect(page.getByRole("heading", { name: "All Events" })).toBeVisible();
|
||||
await expect(page.getByText("E2E Free Evening")).toBeVisible();
|
||||
const course = page.locator("div.border.rounded-xl", { hasText: "E2E Alpha Course" });
|
||||
await expect(course.getByText(/5 sessions · /)).toBeVisible();
|
||||
});
|
||||
|
||||
test("a multi-day event page shows the schedule and the late-joiner price", async ({ page }) => {
|
||||
await page.goto(`/events/${seed().events.course}`);
|
||||
await expect(page.getByRole("heading", { name: "E2E Alpha Course" })).toBeVisible();
|
||||
const schedule = page.locator("div.rounded-xl", { has: page.getByRole("heading", { name: "Schedule" }) });
|
||||
await expect(schedule.getByText("Week 1")).toBeVisible();
|
||||
await expect(schedule.getByText("Week 5")).toBeVisible();
|
||||
await expect(schedule.getByText("Next")).toBeVisible();
|
||||
// Week 2 is in progress, so 3 of 5 are still to come: R300 × 3/5 = R180.
|
||||
await expect(page.getByText(/3 of 5 sessions are still to come/).filter({ visible: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("R180.00").filter({ visible: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("an unknown certificate code is reported as not found", async ({ page }) => {
|
||||
await page.goto("/certificates/verify/ABCDE-12345");
|
||||
await expect(page.getByRole("heading", { name: "Certificate not found" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
type Registration = { id: string; status: string; eventId: string };
|
||||
type Ticket = { id: string; qrCode: string; eventId: string };
|
||||
|
||||
type CashByUser = { rows: { userId: string | null; cash: { total: number; count: number } }[] };
|
||||
|
||||
test.describe("registration → payment → ticket → check-in → cashup", () => {
|
||||
test("a free event: register and get a ticket straight away", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.free}`);
|
||||
await expect(page.getByText("E2E Free Evening").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await page.waitForURL(/\/registration\/success/);
|
||||
|
||||
const tickets = await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets");
|
||||
expect(tickets.some((t) => t.eventId === seed().events.free)).toBe(true);
|
||||
});
|
||||
|
||||
test("a paid event: pending until paid, then a ticket that checks in once", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.paid}`);
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await expect(page.getByText("Total: R150.00")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await page.waitForURL(/\/registration\/success/);
|
||||
|
||||
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
|
||||
const reg = regs.find((r) => r.eventId === seed().events.paid);
|
||||
expect(reg?.status).not.toBe("paid");
|
||||
expect((await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets")).some((t) => t.eventId === seed().events.paid)).toBe(false);
|
||||
|
||||
// A supervisor records the cash payment at the desk.
|
||||
await api(page, "supervisor", "POST", "/api/payments", { amount: 150, method: "cash", registrationId: reg!.id, eventId: seed().events.paid, userId: seed().users.user.id });
|
||||
const ticket = (await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets")).find((t) => t.eventId === seed().events.paid);
|
||||
expect(ticket).toBeTruthy();
|
||||
|
||||
// My Events shows it.
|
||||
await page.goto("/dashboard/user");
|
||||
await expect(page.getByText("E2E Paid Conference").first()).toBeVisible();
|
||||
|
||||
// Staff check it in; a second scan is refused.
|
||||
await api(page, "staff", "POST", `/api/tickets/scan/${ticket!.qrCode}`, { qty: 1 });
|
||||
await expect(api(page, "staff", "POST", `/api/tickets/scan/${ticket!.qrCode}`, { qty: 1 })).rejects.toThrow(/403.*fully used/);
|
||||
|
||||
// Cashup: the R150 cash is on the supervisor who took it.
|
||||
const cash = await api<CashByUser>(page, "supervisor", "GET", `/api/cashups/event/${seed().events.paid}/cash-by-user`);
|
||||
const mine = cash.rows.find((r) => r.userId === seed().users.supervisor.id);
|
||||
expect(mine?.cash.total).toBe(150);
|
||||
expect(mine?.cash.count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
test.describe("multi-day events", () => {
|
||||
test("check-in is once per session, and the register and certificates follow", async ({ page }) => {
|
||||
const { course, events } = seed();
|
||||
|
||||
// Scanning today: Week 2.
|
||||
const scan = await api<{ session: { label: string } }>(page, "staff", "POST", `/api/tickets/scan/${course.qrCode}`, { qty: 1 });
|
||||
expect(scan.session.label).toBe("Week 2");
|
||||
await expect(api(page, "staff", "POST", `/api/tickets/scan/${course.qrCode}`, { qty: 1 })).rejects.toThrow(/Already checked in for Week 2/);
|
||||
|
||||
// The register shows weeks 1 and 2 ticked.
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto(`/dashboard/supervisor/attendance?eventId=${events.course}`);
|
||||
const row = page.locator("tr", { hasText: "Thandi Test" });
|
||||
await expect(row.getByText("2/5")).toBeVisible();
|
||||
|
||||
// Tick week 3 by hand (e.g. let in without a scan), then untick it.
|
||||
const week3 = row.locator("td").nth(3).getByRole("button");
|
||||
await week3.click();
|
||||
await expect(row.getByText("3/5")).toBeVisible();
|
||||
await week3.click();
|
||||
await expect(row.getByText("2/5")).toBeVisible();
|
||||
|
||||
// Two sessions meets the rule (at least 2), so a certificate can be issued now.
|
||||
await page.getByRole("button", { name: "Issue now" }).click();
|
||||
await expect(page.getByText("1 certificate issued.")).toBeVisible();
|
||||
const code = (await row.locator("span.font-mono").innerText()).trim();
|
||||
expect(code).toMatch(/^[A-Z0-9]{5}-[A-Z0-9]{5}$/);
|
||||
|
||||
// Anyone can check it.
|
||||
await page.goto(`/certificates/verify/${code}`);
|
||||
await expect(page.getByRole("heading", { name: "Valid certificate" })).toBeVisible();
|
||||
await expect(page.getByText("Thandi Test")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the attendee sees their certificate on My Events", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto("/dashboard/user");
|
||||
await expect(page.getByRole("heading", { name: "My Certificates" })).toBeVisible();
|
||||
await expect(page.getByText("E2E Alpha Course").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("the scanner offers today's session for a multi-day event", async ({ page }) => {
|
||||
await loginAs(page, "staff");
|
||||
await page.goto("/dashboard/staff/ticket-scanning");
|
||||
await page.locator("select").first().selectOption({ label: "E2E Alpha Course" });
|
||||
await expect(page.locator("select", { has: page.locator("option", { hasText: "Today's session" }) })).toBeVisible();
|
||||
await expect(page.getByText("Today's session (Week 2)")).toBeAttached();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
type EventRow = { id: string; title: string; scheduleType?: string; sessions?: { cancelled?: boolean }[] };
|
||||
|
||||
test.describe("supervisor tools", () => {
|
||||
test("create a weekly multi-session event in the wizard", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/events");
|
||||
await page.getByRole("button", { name: "+ Add Event" }).click();
|
||||
await expect(page.getByRole("heading", { name: "New Event" })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Event title").fill("E2E Wizard Course");
|
||||
await page.getByRole("button", { name: "Multiple sessions" }).click();
|
||||
// The repeat pattern defaults to weekly, 18:30–20:30, 5 sessions from today.
|
||||
await page.getByRole("button", { name: "Create sessions" }).click();
|
||||
await expect(page.getByText(/^5 sessions — edit any date/)).toBeVisible();
|
||||
await page.locator('input[type="number"][placeholder="0"]').first().fill("250");
|
||||
|
||||
// Through the remaining steps to Create Event.
|
||||
const create = page.getByRole("button", { name: "Create Event" });
|
||||
for (let i = 0; i < 15 && !(await create.isVisible()); i++) {
|
||||
await page.getByRole("button", { name: "Next →" }).click();
|
||||
}
|
||||
await create.click();
|
||||
await expect(page.getByRole("heading", { name: "New Event" })).toBeHidden();
|
||||
|
||||
const row = page.locator("li", { hasText: "E2E Wizard Course" });
|
||||
await expect(row.getByText(/5 sessions · /)).toBeVisible();
|
||||
const all = await api<EventRow[]>(page, "supervisor", "GET", "/api/events/all");
|
||||
const created = all.find((e) => e.title === "E2E Wizard Course");
|
||||
expect(created?.scheduleType).toBe("sessions");
|
||||
expect(created?.sessions?.filter((s) => !s.cancelled)).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("self-service kiosk: a visitor registers for a free event", async ({ page }) => {
|
||||
await page.goto("/self-service");
|
||||
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
|
||||
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
|
||||
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
|
||||
await page.locator("select").selectOption(value!);
|
||||
await page.getByRole("button", { name: "Start Kiosk" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
|
||||
await page.getByPlaceholder("john@example.com").fill("walkin@e2e.test");
|
||||
await page.getByPlaceholder("+27 82 000 0000").fill("0820000099");
|
||||
await page.getByPlaceholder("John Smith").fill("Walk In Visitor");
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "You're Registered!" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("admin: background jobs page", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/settings?tab=jobs");
|
||||
await expect(page.getByRole("heading", { name: "Background jobs" })).toBeVisible();
|
||||
// The e2e backend runs with JOBS_ENABLED=false.
|
||||
await expect(page.getByText(/All background jobs are switched off/)).toBeVisible();
|
||||
await expect(page.getByText("Session reminders and certificates")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// The e2e database: E2E_DATABASE_URL, or backend/.env's DATABASE_URL with "_e2e" added
|
||||
// to the database name — always separate from the database you develop against.
|
||||
const path = require('path');
|
||||
|
||||
function e2eDatabaseUrl() {
|
||||
if (process.env.E2E_DATABASE_URL) return process.env.E2E_DATABASE_URL;
|
||||
const backend = path.join(__dirname, '..', '..', 'backend');
|
||||
require(require.resolve('dotenv', { paths: [backend] })).config({ path: path.join(backend, '.env'), quiet: true });
|
||||
if (!process.env.DATABASE_URL) throw new Error('Set E2E_DATABASE_URL (or DATABASE_URL in backend/.env) for the e2e tests.');
|
||||
const u = new URL(process.env.DATABASE_URL);
|
||||
const name = u.pathname.replace(/^\//, '');
|
||||
u.pathname = `/${name.endsWith('_e2e') ? name : `${name}_e2e`}`;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
module.exports = { e2eDatabaseUrl };
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Prepares the end-to-end test database, then exits. Run before the e2e backend starts
|
||||
* (see playwright.config.ts): drops and recreates the database, applies the migrations,
|
||||
* and seeds settings, users and events. Writes e2e/.data/seed.json with the ids and
|
||||
* login tokens the tests use.
|
||||
*
|
||||
* Database: E2E_DATABASE_URL, or backend/.env's DATABASE_URL with "_e2e" added to the
|
||||
* database name. It is always a separate database — never the one you develop against.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const backend = path.join(root, 'backend');
|
||||
const req = (m) => require(require.resolve(m, { paths: [backend] }));
|
||||
req('dotenv').config({ path: path.join(backend, '.env') });
|
||||
|
||||
const { e2eDatabaseUrl } = require('./db-url');
|
||||
|
||||
async function recreateDatabase(url) {
|
||||
const { Client } = req('pg');
|
||||
const target = new URL(url);
|
||||
const dbName = decodeURIComponent(target.pathname.replace(/^\//, ''));
|
||||
if (!/_e2e$|^e2e|test/i.test(dbName)) throw new Error(`Refusing to reset "${dbName}" — the e2e database name must contain "e2e" or "test".`);
|
||||
const admin = new URL(url);
|
||||
admin.pathname = '/postgres';
|
||||
admin.search = '';
|
||||
const client = new Client({ connectionString: admin.toString() });
|
||||
await client.connect();
|
||||
await client.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
|
||||
await client.query(`CREATE DATABASE "${dbName}"`);
|
||||
await client.end();
|
||||
}
|
||||
|
||||
async function seed(url) {
|
||||
process.env.DATABASE_URL = url;
|
||||
const prisma = require(path.join(backend, 'src/config/db'));
|
||||
const { generateToken, hashPassword } = require(path.join(backend, 'src/config/auth'));
|
||||
const { generateTicketsForRegistration } = require(path.join(backend, 'src/utils/ticketUtils'));
|
||||
|
||||
await prisma.appSetting.createMany({
|
||||
data: [
|
||||
{ key: 'setup_complete', value: 'true' },
|
||||
{ key: 'org_name', value: 'E2E Church' },
|
||||
{ key: 'org_address', value: '1 Test Street, Pretoria' },
|
||||
],
|
||||
});
|
||||
|
||||
const password = await hashPassword('E2e-password-123');
|
||||
const mk = (role, name, email, phoneNumber) => prisma.user.create({
|
||||
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date() },
|
||||
});
|
||||
const users = {
|
||||
admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'),
|
||||
supervisor: await mk('supervisor', 'Sam Supervisor', 'supervisor@e2e.test', '0820000002'),
|
||||
staff: await mk('staff', 'Stan Staff', 'staff@e2e.test', '0820000003'),
|
||||
user: await mk('user', 'Thandi Test', 'user@e2e.test', '0820000004'),
|
||||
};
|
||||
|
||||
const D = 86400000;
|
||||
const now = Date.now();
|
||||
const at = (days, hourUtc = 16, minute = 30) => {
|
||||
const d = new Date(now + days * D);
|
||||
d.setUTCHours(hourUtc, minute, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
const free = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Free Evening', description: 'A free evening for the tests.', startDate: at(1), endDate: at(1, 19),
|
||||
price: 0, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
|
||||
},
|
||||
});
|
||||
const paid = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Paid Conference', description: 'A paid one-day conference.', startDate: at(3, 7), endDate: at(3, 15),
|
||||
price: 150, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
eventOptions: { create: [{ name: 'Conference ticket', price: 150, isMainTicket: true }] },
|
||||
},
|
||||
});
|
||||
|
||||
// Multi-day: week 1 was a week ago, week 2 is in progress now, then three more weeks.
|
||||
const todayStart = new Date(now - 30 * 60 * 1000);
|
||||
const sessions = [-7, 0, 7, 14, 21].map((days, i) => {
|
||||
const startsAt = new Date(todayStart.getTime() + days * D);
|
||||
return { label: `Week ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 90 * 60 * 1000), order: i };
|
||||
});
|
||||
const course = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Alpha Course', description: 'Five weekly evenings.', startDate: sessions[0].startsAt, endDate: sessions[4].endsAt,
|
||||
price: 300, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
scheduleType: 'sessions', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
|
||||
certificatesEnabled: true,
|
||||
certificateConfig: { template: 'classic', title: 'Certificate of Completion', body: '{name} completed {event}.', rule: { type: 'count', value: 2 } },
|
||||
sessions: { create: sessions },
|
||||
eventOptions: { create: [{ name: 'Course ticket', price: 300, isMainTicket: true }] },
|
||||
},
|
||||
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
|
||||
});
|
||||
await prisma.event.update({ where: { id: course.id }, data: { lateJoinUntilSessionId: course.sessions[2].id } });
|
||||
|
||||
// A drop-in class: three upcoming classes, booked per class (the first is full).
|
||||
const classes = [1, 8, 15].map((days, i) => {
|
||||
const startsAt = at(days, 16, 0);
|
||||
return { label: `Class ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 60 * 60 * 1000), order: i, capacity: i === 0 ? 1 : null };
|
||||
});
|
||||
const dropIn = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Drop-in Class', description: 'Come to the classes you like.', startDate: classes[0].startsAt, endDate: classes[2].endsAt,
|
||||
price: 50, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
scheduleType: 'sessions', attendanceMode: 'pick',
|
||||
sessions: { create: classes },
|
||||
eventOptions: { create: [{ name: 'Drop-in', price: 50, isMainTicket: true, sessionMode: 'pick' }] },
|
||||
},
|
||||
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
|
||||
});
|
||||
// Someone else already has the only place in class 1.
|
||||
await prisma.registration.create({
|
||||
data: {
|
||||
userId: users.staff.id, eventId: dropIn.id, status: 'paid',
|
||||
registrationOptions: { create: [{ eventOptionId: dropIn.eventOptions[0].id, quantity: 1, priceSnapshot: 50, sessionId: dropIn.sessions[0].id, tranches: { create: [{ quantity: 1, priceSnapshot: 50 }] } }] },
|
||||
},
|
||||
});
|
||||
|
||||
// Thandi is booked on the course and was checked in for week 1.
|
||||
const reg = await prisma.registration.create({
|
||||
data: {
|
||||
userId: users.user.id, eventId: course.id, status: 'paid',
|
||||
registrationOptions: { create: [{ eventOptionId: course.eventOptions[0].id, quantity: 1, priceSnapshot: 300, tranches: { create: [{ quantity: 1, priceSnapshot: 300 }] } }] },
|
||||
},
|
||||
});
|
||||
await prisma.payment.create({ data: { amount: 300, method: 'cash', userId: users.user.id, registrationId: reg.id, eventId: course.id, recordedById: users.supervisor.id } });
|
||||
await generateTicketsForRegistration(reg.id);
|
||||
const ticket = await prisma.ticket.findFirst({ where: { eventId: course.id } });
|
||||
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: users.staff.id, sessionId: course.sessions[0].id, scannedAt: course.sessions[0].startsAt } });
|
||||
|
||||
const out = {
|
||||
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
|
||||
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id },
|
||||
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
|
||||
};
|
||||
fs.mkdirSync(path.join(root, 'e2e', '.data'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'e2e', '.data', 'seed.json'), JSON.stringify(out, null, 2));
|
||||
await prisma.$disconnect?.();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const url = e2eDatabaseUrl();
|
||||
await recreateDatabase(url);
|
||||
execSync('npx prisma migrate deploy', { cwd: backend, env: { ...process.env, DATABASE_URL: url }, stdio: 'ignore' });
|
||||
await seed(url);
|
||||
console.log('[e2e] Database ready:', new URL(url).pathname.slice(1));
|
||||
process.exit(0);
|
||||
})().catch((e) => {
|
||||
console.error('[e2e] Preparing the database failed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -2,12 +2,12 @@ import type { NextConfig } from "next";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// The end-to-end tests run their own dev server; a separate build folder keeps it
|
||||
// from clashing with one already running.
|
||||
distDir: process.env.NEXT_DIST_DIR || ".next",
|
||||
images: {
|
||||
domains: ['images.unsplash.com'],
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.24.0",
|
||||
"version": "1.35.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.24.0",
|
||||
"version": "1.35.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
@@ -8478,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.24.0",
|
||||
"version": "1.35.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -62,7 +62,8 @@
|
||||
"overrides": {
|
||||
"next": {
|
||||
"postcss": "^8.5.23"
|
||||
}
|
||||
},
|
||||
"postcss-selector-parser": "^7.1.7"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@sentry/cli@2.58.6": true,
|
||||
|
||||
@@ -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,9 +34,9 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -47,9 +49,9 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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();
|
||||
@@ -24,14 +25,14 @@ function ActivateAccountContent() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const data = await apiFetch<any>("/api/users/activate", { method: "POST", body: { token, password } });
|
||||
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(() => { window.location.href = "/dashboard"; }, 1500);
|
||||
} catch (err: any) {
|
||||
setStatus(err?.message || "Activation failed. The link may have expired.");
|
||||
} catch (err) {
|
||||
setStatus(errorMessage(err) || "Activation failed. The link may have expired.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Metadata } from "next";
|
||||
import { BadgeCheck, ShieldX } from "lucide-react";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Verify a certificate",
|
||||
robots: { index: false },
|
||||
};
|
||||
|
||||
type Verification = { valid: boolean; revoked?: boolean; name?: string; event?: string; completedOn?: string; issuedAt?: string };
|
||||
|
||||
// Public check for the QR code / code printed on a completion certificate. Shows only the
|
||||
// name, event and dates — nothing else about the person.
|
||||
export default async function VerifyCertificatePage({ params }: { params: Promise<{ code: string }> }) {
|
||||
const { code } = await params;
|
||||
let result: Verification = { valid: false };
|
||||
try {
|
||||
result = await apiFetch<Verification>(`/api/attendance/certificates/verify/${encodeURIComponent(code)}`, { nextOptions: { cache: "no-store" } });
|
||||
} catch {
|
||||
result = { valid: false };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 py-12 px-4 max-w-lg mx-auto w-full">
|
||||
<div className="border rounded-xl p-6 bg-white shadow-sm text-center">
|
||||
{result.valid ? (
|
||||
<>
|
||||
<BadgeCheck className="w-12 h-12 text-green-600 mx-auto mb-3" />
|
||||
<h1 className="text-xl font-semibold text-gray-900">Valid certificate</h1>
|
||||
<p className="text-gray-700 mt-3"><span className="font-semibold">{result.name}</span> completed</p>
|
||||
<p className="text-lg font-medium text-brand-700 mt-1">{result.event}</p>
|
||||
<p className="text-sm text-gray-500 mt-3">
|
||||
{result.completedOn && <>Completed {formatDate(new Date(result.completedOn))} · </>}
|
||||
{result.issuedAt && <>Issued {formatDate(new Date(result.issuedAt))}</>}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldX className="w-12 h-12 text-red-500 mx-auto mb-3" />
|
||||
<h1 className="text-xl font-semibold text-gray-900">{result.revoked ? "This certificate was withdrawn" : "Certificate not found"}</h1>
|
||||
<p className="text-sm text-gray-500 mt-3">
|
||||
{result.revoked
|
||||
? "The organiser has withdrawn this certificate, so it's no longer valid."
|
||||
: "Check the code on the certificate and try again. Codes look like K7M2Q-9XRTB."}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-6 font-mono">{decodeURIComponent(code).toUpperCase()}</p>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +24,9 @@ const ACTIONS = [
|
||||
"coupon_updated",
|
||||
"coupon_disabled",
|
||||
"coupon_applied_manual",
|
||||
"bundle_created",
|
||||
"bundle_updated",
|
||||
"bundle_deleted",
|
||||
"consent_override",
|
||||
"consent_rerequested",
|
||||
"medical_list_viewed",
|
||||
@@ -32,6 +37,9 @@ const ACTIONS = [
|
||||
"ticket_transferred",
|
||||
"impersonation_started",
|
||||
"impersonation_ended",
|
||||
"yoco_config_updated",
|
||||
"yoco_webhook_registered",
|
||||
"yoco_webhook_deleted",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
@@ -48,6 +56,9 @@ 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",
|
||||
@@ -58,6 +69,9 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
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 {
|
||||
@@ -168,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">
|
||||
@@ -184,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>
|
||||
|
||||
@@ -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, WifiOff, BedDouble, Filter,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
|
||||
} 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)}`;
|
||||
@@ -29,9 +30,11 @@ const QUICK_ACTIONS = [
|
||||
{ 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/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck },
|
||||
{ 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 },
|
||||
@@ -67,35 +70,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.
|
||||
@@ -105,7 +108,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 (
|
||||
|
||||
@@ -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,22 @@
|
||||
|
||||
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, Timer, type LucideIcon } from "lucide-react";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
import { JobsTab } from "@/components/admin/JobsTab";
|
||||
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" | "jobs";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -22,12 +26,20 @@ 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 },
|
||||
{ id: "jobs", label: "Background jobs", icon: Timer },
|
||||
];
|
||||
|
||||
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 +94,6 @@ export default function SiteSettingsPage() {
|
||||
|
||||
function SiteSettingsPageInner() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { reload: reloadSettings } = useSiteSettings();
|
||||
|
||||
@@ -101,6 +112,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");
|
||||
@@ -150,6 +162,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
|
||||
@@ -176,9 +189,9 @@ function SiteSettingsPageInner() {
|
||||
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); };
|
||||
@@ -192,8 +205,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);
|
||||
}
|
||||
@@ -206,6 +219,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> => {
|
||||
@@ -251,8 +265,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);
|
||||
}
|
||||
@@ -276,8 +290,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);
|
||||
}
|
||||
@@ -310,8 +324,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);
|
||||
}
|
||||
@@ -345,8 +359,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);
|
||||
}
|
||||
@@ -362,15 +377,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 */}
|
||||
@@ -433,6 +443,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>
|
||||
)}
|
||||
@@ -688,8 +702,12 @@ function SiteSettingsPageInner() {
|
||||
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
|
||||
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
|
||||
|
||||
{/* ── Yoco ──────────────────────────────────────────────────────────── */}
|
||||
{activeTab === "yoco" && <YocoTab active={activeTab === "yoco"} />}
|
||||
|
||||
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
|
||||
</div>
|
||||
|
||||
{activeTab === "branding" && (
|
||||
@@ -870,7 +888,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);
|
||||
@@ -914,12 +932,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")) {
|
||||
@@ -942,8 +960,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);
|
||||
}
|
||||
@@ -960,8 +978,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);
|
||||
}
|
||||
@@ -971,11 +989,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);
|
||||
}
|
||||
@@ -1219,8 +1237,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,9 +5,12 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { beginImpersonation } from "@/lib/impersonation";
|
||||
import { 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;
|
||||
@@ -27,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();
|
||||
@@ -98,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 = () => {
|
||||
@@ -129,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 },
|
||||
});
|
||||
@@ -141,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);
|
||||
}
|
||||
@@ -155,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,
|
||||
@@ -172,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);
|
||||
}
|
||||
@@ -183,8 +177,8 @@ 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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,10 +191,10 @@ export default function AdminUsersPage() {
|
||||
setImpBusy(true);
|
||||
setImpError(null);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
|
||||
const res = await apiFetch<ImpersonationStart>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
|
||||
beginImpersonation(res);
|
||||
} catch (e: any) {
|
||||
setImpError(e?.message || "Couldn't start impersonation");
|
||||
} catch (e) {
|
||||
setImpError(errorMessage(e) || "Couldn't start impersonation");
|
||||
setImpBusy(false);
|
||||
}
|
||||
};
|
||||
@@ -215,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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -235,8 +229,8 @@ 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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -266,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>
|
||||
|
||||
@@ -6,8 +6,12 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { printTicketCards } from "@/lib/ticketPrint";
|
||||
import { QrImage } from "@/components/shared/QrImage";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, Ticket as TicketRecord } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
function EventTicketsContent() {
|
||||
const { token, user } = useAuth();
|
||||
@@ -17,9 +21,9 @@ function EventTicketsContent() {
|
||||
const paramEventId = params.get("eventId") || "";
|
||||
const paramUserId = params.get("userId") || "";
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(paramEventId);
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -36,7 +40,7 @@ function EventTicketsContent() {
|
||||
(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
const now = Date.now();
|
||||
const active = (evs || []).filter((ev) => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
@@ -47,25 +51,27 @@ function EventTicketsContent() {
|
||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||
);
|
||||
setEvents(active);
|
||||
if (!paramEventId && active.length > 0) {
|
||||
setEventId(active[0].id);
|
||||
router.replace(
|
||||
`?eventId=${encodeURIComponent(active[0].id)}${
|
||||
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
}, [token, setError]);
|
||||
|
||||
// Sync params → state
|
||||
// No event in the URL: default to the first upcoming one once the list arrives.
|
||||
useEffect(() => {
|
||||
if (paramEventId && paramEventId !== eventId) setEventId(paramEventId);
|
||||
if (paramUserId && paramUserId !== selectedUserId)
|
||||
setSelectedUserId(paramUserId);
|
||||
if (paramEventId || events.length === 0) return;
|
||||
setEventId(events[0].id);
|
||||
router.replace(
|
||||
`?eventId=${encodeURIComponent(events[0].id)}${
|
||||
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
|
||||
}`
|
||||
);
|
||||
}, [events, paramEventId, router, selectedUserId]);
|
||||
|
||||
// Sync params → state (setting the value it already has is a no-op)
|
||||
useEffect(() => {
|
||||
if (paramEventId) setEventId(paramEventId);
|
||||
if (paramUserId) setSelectedUserId(paramUserId);
|
||||
}, [paramEventId, paramUserId]);
|
||||
|
||||
// Load tickets when event changes
|
||||
@@ -75,18 +81,18 @@ function EventTicketsContent() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await apiFetch<any[]>(
|
||||
const list = await apiFetch<TicketRecord[]>(
|
||||
`/api/tickets/event/${encodeURIComponent(eventId)}`,
|
||||
{ authToken: token }
|
||||
);
|
||||
setTickets(list);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load tickets");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load tickets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, eventId]);
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
// Build variants list for filter (only variants present in current event's tickets)
|
||||
const variantsList = useMemo(() => {
|
||||
@@ -137,96 +143,7 @@ function EventTicketsContent() {
|
||||
return list;
|
||||
}, [tickets, eventId, selectedUserId, selectedVariantId]);
|
||||
|
||||
// Printing helpers
|
||||
const buildTicketHtmlCard = (t: any) => {
|
||||
const eventTitle = t.event?.title || t.eventId || "Event";
|
||||
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
||||
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
|
||||
const optVariant = t.registrationOption?.variant?.name;
|
||||
const type = optVariant ? `${optBase} — ${optVariant}` : optBase;
|
||||
const qty = t.quantity || 1;
|
||||
const holder = t.attendee?.name ? `${t.attendee.name}${t.user?.name ? ` (booked by ${t.user.name})` : ""}` : (t.user?.name || t.userId || "");
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent(
|
||||
t.qrCode || t.id
|
||||
)}`;
|
||||
const dateStr = eventDate ? formatDate(eventDate) : "";
|
||||
return `
|
||||
<div class="ticket">
|
||||
<div class="evt">${eventTitle}</div>
|
||||
${dateStr ? `<div class="date">${dateStr}</div>` : ""}
|
||||
<div class="type">${type}</div>
|
||||
<div class="qty">Qty: <strong>${qty}</strong></div>
|
||||
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
|
||||
<div class="meta">ID: ${t.id}</div>
|
||||
${holder ? `<div class="meta">${holder}</div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const openPrintWindow = (pagesHtml: string) => {
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
w.document.write(`<!doctype html><html><head><title>Tickets</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; }
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
height: 277mm;
|
||||
gap: 6mm;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
}
|
||||
.page:last-child { page-break-after: avoid; break-after: avoid; }
|
||||
.ticket { border: 1px solid #000; padding: 5mm; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; }
|
||||
.evt { font-weight: bold; font-size: 14px; }
|
||||
.type { font-size: 12px; margin-top: 2mm; }
|
||||
.qty { font-size: 11px; margin-top: 1mm; }
|
||||
.date { font-size: 10px; color: #555; margin-top: 1mm; }
|
||||
.qrwrap { display: flex; justify-content: center; margin: 2mm 0; }
|
||||
.qrwrap img { width: 30mm; height: 30mm; }
|
||||
.meta { font-size: 10px; text-align: center; }
|
||||
</style>
|
||||
</head><body>
|
||||
${pagesHtml}
|
||||
<script>
|
||||
(function(){
|
||||
function printWhenReady(){
|
||||
var imgs = Array.prototype.slice.call(document.images);
|
||||
if(imgs.length === 0){ window.print(); return; }
|
||||
var remaining = imgs.length;
|
||||
function done(){ remaining--; if(remaining <= 0){ setTimeout(function(){ window.print(); }, 100); } }
|
||||
imgs.forEach(function(img){
|
||||
if (img.complete) { done(); }
|
||||
else {
|
||||
img.addEventListener('load', done, { once: true });
|
||||
img.addEventListener('error', done, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'complete') printWhenReady();
|
||||
else window.addEventListener('load', printWhenReady);
|
||||
})();
|
||||
</script>
|
||||
</body></html>`);
|
||||
w.document.close();
|
||||
w.focus();
|
||||
};
|
||||
|
||||
const printTickets = (ticketList: any[]) => {
|
||||
if (!ticketList || ticketList.length === 0) return;
|
||||
// Chunk into pages of 8 (2 columns × 4 rows per A4 sheet)
|
||||
const PAGE_SIZE = 8;
|
||||
const pages: string[] = [];
|
||||
for (let i = 0; i < ticketList.length; i += PAGE_SIZE) {
|
||||
const chunk = ticketList.slice(i, i + PAGE_SIZE);
|
||||
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
|
||||
}
|
||||
openPrintWindow(pages.join(""));
|
||||
};
|
||||
const printTickets = (ticketList: TicketRecord[]) => printTicketCards(ticketList, (t) => t.user?.name);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
@@ -235,14 +152,11 @@ function EventTicketsContent() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Ticket className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Event Tickets</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Event Tickets</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
View and print tickets for an event.
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { Users } from "lucide-react";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type Member = {
|
||||
id: string; name: string; gender: string | null; dateOfBirth: string | null; phone: string | null;
|
||||
@@ -25,7 +25,6 @@ const ageOn = (dob: string | null, on: string) => {
|
||||
/** Groups the signed-in staff member leads (rooms, small groups, buses) and who's in them. */
|
||||
export default function MyGroupsPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<{ medicalShown: boolean; groups: MyGroup[] } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -42,7 +41,7 @@ export default function MyGroupsPage() {
|
||||
<Users className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">My groups</h1>
|
||||
<p className="text-sm text-gray-500">Rooms, small groups and buses you lead</p>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
"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";
|
||||
import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck } from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
|
||||
{ href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users },
|
||||
{ href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session of an event", icon: ClipboardCheck },
|
||||
] as const;
|
||||
|
||||
export default function StaffDashboardPage() {
|
||||
@@ -35,31 +37,31 @@ export default function StaffDashboardPage() {
|
||||
// (/api/stats/staff) instead of two separate calls. useStableState skips re-renders when
|
||||
// a poll returns identical data, and hasLoadedOnce below means "Refreshing…" only shows
|
||||
// on the very first load — together these stop the stats panel from flickering.
|
||||
const [stats, setStats] = useStableState<any | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<any[]>([]);
|
||||
const [stats, setStats] = useStableState<ScanStats | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<RecentScan[]>([]);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const loadStats = async () => {
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const isFirstLoad = !hasLoadedOnce.current;
|
||||
try {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const data = await apiFetch<any>("/api/stats/staff", { authToken: token });
|
||||
const data = await apiFetch<DashboardStats>("/api/stats/staff", { authToken: token });
|
||||
setStats(data.scanStats);
|
||||
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
hasLoadedOnce.current = true;
|
||||
if (isFirstLoad) setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
}, [token, setStats, setRecentScans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadStats();
|
||||
}, [token]);
|
||||
}, [token, loadStats]);
|
||||
|
||||
// Poll every 10s while the tab is visible; pause in the background and refetch
|
||||
// immediately on return instead of leaving stale numbers up.
|
||||
@@ -104,7 +106,7 @@ export default function StaffDashboardPage() {
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
{recentScans.map((u) => (
|
||||
<li key={u.id} className="border rounded p-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
||||
@@ -122,9 +124,9 @@ export default function StaffDashboardPage() {
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Today by staff</h2>
|
||||
{stats?.byStaff?.length > 0 ? (
|
||||
{stats && stats.byStaff?.length > 0 ? (
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{stats.byStaff.map((s: any) => (
|
||||
{stats.byStaff.map((s) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
|
||||
@@ -1,28 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { QrCode } from "lucide-react";
|
||||
import { useOfflineScanning } from "@/components/scanner/useOfflineScanning";
|
||||
import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
|
||||
import type { SnapshotTicket } from "@/lib/offlineScan";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, RecentScan, ScanStats, Section } from "@/types";
|
||||
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
// GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline
|
||||
// scans build the same shape from the downloaded ticket list.
|
||||
type ScanTicket = {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
eventOptionId?: string;
|
||||
quantity?: number;
|
||||
consentStatus?: string;
|
||||
event?: { id?: string; title?: string } | null;
|
||||
registrationOption?: {
|
||||
eventOption?: { id?: string; name?: string } | null;
|
||||
variant?: { name?: string } | null;
|
||||
registration?: { user?: { name?: string } | null } | null;
|
||||
} | null;
|
||||
attendee?: { name: string } | null;
|
||||
user?: { name?: string; email?: string } | null;
|
||||
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
|
||||
// Multi-day events: the session this scan counts for, and what's left for it.
|
||||
remaining?: number;
|
||||
session?: { id: string; label: string } | null;
|
||||
sessionError?: string | null;
|
||||
};
|
||||
|
||||
export default function TicketScanningPage() {
|
||||
const router = useRouter();
|
||||
const scannerRef = useRef<QRScannerHandle>(null);
|
||||
const [lastResult, setLastResult] = useState<string | null>(null);
|
||||
const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
|
||||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: ScanTicket } | null>(null);
|
||||
const [errorModal, setErrorModal] = useState<string | null>(null);
|
||||
const { token, user } = useAuth();
|
||||
const offline = useOfflineScanning(token);
|
||||
const [recentScans, setRecentScans] = useState<any[]>([]);
|
||||
const [stats, setStats] = useState<any | null>(null);
|
||||
const [recentScans, setRecentScans] = useState<RecentScan[]>([]);
|
||||
const [stats, setStats] = useState<ScanStats | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState<boolean>(false);
|
||||
const [successModal, setSuccessModal] = useState<{
|
||||
optionName: string;
|
||||
@@ -36,7 +60,7 @@ export default function TicketScanningPage() {
|
||||
// Confirm step state
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
qrCode: string;
|
||||
ticket: any;
|
||||
ticket: ScanTicket;
|
||||
remaining: number;
|
||||
offlineTicket?: SnapshotTicket; // scanned against the downloaded list (offline mode)
|
||||
} | null>(null);
|
||||
@@ -47,13 +71,15 @@ export default function TicketScanningPage() {
|
||||
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
|
||||
|
||||
// Event selection state
|
||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [includePastEvents, setIncludePastEvents] = useState(false);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("all");
|
||||
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
|
||||
const [sections, setSections] = useState<any[]>([]);
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
|
||||
const [loadingSections, setLoadingSections] = useState(false);
|
||||
// Multi-day events: "auto" = today's session (the server picks it).
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string>("auto");
|
||||
|
||||
// All events from API (including past); frontend filters to 12h window or all past
|
||||
useEffect(() => {
|
||||
@@ -61,10 +87,10 @@ export default function TicketScanningPage() {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingEvents(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
|
||||
const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime());
|
||||
setAllEvents(sorted);
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
// non-fatal
|
||||
} finally {
|
||||
setLoadingEvents(false);
|
||||
@@ -81,6 +107,11 @@ export default function TicketScanningPage() {
|
||||
});
|
||||
}, [allEvents, includePastEvents]);
|
||||
|
||||
const selectedEvent = useMemo(() => allEvents.find(ev => ev.id === selectedEventId) || null, [allEvents, selectedEventId]);
|
||||
const eventSessions = useMemo(() => selectedEvent?.scheduleType === "sessions" ? activeSessions(selectedEvent.sessions) : [], [selectedEvent]);
|
||||
useEffect(() => { setSelectedSessionId("auto"); }, [selectedEventId]);
|
||||
const sessionParam = selectedEventId !== "all" && selectedSessionId !== "auto" ? selectedSessionId : null;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!token || !selectedEventId || selectedEventId === "all") {
|
||||
@@ -90,7 +121,7 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
try {
|
||||
setLoadingSections(true);
|
||||
const list = await apiFetch<any[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
|
||||
const list = await apiFetch<Section[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
|
||||
setSections(Array.isArray(list) ? list : []);
|
||||
setSelectedSectionId("all");
|
||||
} catch {
|
||||
@@ -117,10 +148,9 @@ export default function TicketScanningPage() {
|
||||
useEffect(() => {
|
||||
if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; }
|
||||
scannerRef.current?.stop();
|
||||
}, [selectedEventId, selectedSectionId]);
|
||||
}, [selectedEventId, selectedSectionId, selectedSessionId]);
|
||||
|
||||
async function handleResult(text: string) {
|
||||
setLastResult(text);
|
||||
setError(null);
|
||||
setScanInfo(null);
|
||||
try {
|
||||
@@ -157,11 +187,11 @@ export default function TicketScanningPage() {
|
||||
|
||||
// Always preview first to validate the ticket before showing confirm step
|
||||
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
|
||||
let preview: any;
|
||||
let preview: ScanTicket;
|
||||
try {
|
||||
preview = await apiFetch<any>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
|
||||
} catch (pe: any) {
|
||||
setErrorModal(pe?.message || 'Failed to validate ticket');
|
||||
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}${sessionParam ? `?sessionId=${encodeURIComponent(sessionParam)}` : ""}`, { authToken: token });
|
||||
} catch (pe) {
|
||||
setErrorModal(errorMessage(pe) || 'Failed to validate ticket');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,7 +212,7 @@ export default function TicketScanningPage() {
|
||||
return;
|
||||
}
|
||||
const section = sections.find(s => s.id === selectedSectionId);
|
||||
const allowedOptionIds = (section?.allowedOptions || []).map((o: any) => o.eventOptionId);
|
||||
const allowedOptionIds = (section?.allowedOptions || []).map((o) => o.eventOptionId);
|
||||
if (!allowedOptionIds.includes(ticketOptionId)) {
|
||||
setErrorModal("This ticket is not valid for this section");
|
||||
return;
|
||||
@@ -190,13 +220,19 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Compute remaining qty
|
||||
// Multi-day events: no session open for check-in now (wrong day, cancelled…).
|
||||
if (preview?.sessionError) {
|
||||
setErrorModal(preview.sessionError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute remaining qty (the server counts it per session for multi-day events)
|
||||
const ticketQty = preview?.quantity || 1;
|
||||
const totalRedeemed = (preview?.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = ticketQty - totalRedeemed;
|
||||
const totalRedeemed = (preview?.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = typeof preview?.remaining === "number" ? preview.remaining : ticketQty - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
setAlreadyUsedModal({ message: 'Ticket has already been fully used', ticket: preview });
|
||||
setAlreadyUsedModal({ message: preview?.session ? `Already checked in for ${preview.session.label}` : 'Ticket has already been fully used', ticket: preview });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,8 +240,8 @@ export default function TicketScanningPage() {
|
||||
setConfirmQty(remaining);
|
||||
setConfirmQtyRaw(String(remaining));
|
||||
setConfirmModal({ qrCode: text, ticket: preview, remaining });
|
||||
} catch (e: any) {
|
||||
setErrorModal(e?.message || "Failed to scan ticket");
|
||||
} catch (e) {
|
||||
setErrorModal(errorMessage(e) || "Failed to scan ticket");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +254,7 @@ export default function TicketScanningPage() {
|
||||
const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket";
|
||||
const variantName = ticket?.registrationOption?.variant?.name;
|
||||
const optionName = variantName ? `${baseName} — ${variantName}` : baseName;
|
||||
const eventTitle = ticket?.event?.title || 'Event';
|
||||
const eventTitle = `${ticket?.event?.title || 'Event'}${ticket?.session ? ` — ${ticket.session.label}` : ''}`;
|
||||
const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : '';
|
||||
const newRemaining = remaining - qty;
|
||||
|
||||
@@ -239,9 +275,9 @@ export default function TicketScanningPage() {
|
||||
if (offlineTicket) {
|
||||
try {
|
||||
await offline.scanLocally(offlineTicket, qty);
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setSuccessModal(null);
|
||||
setErrorModal(e?.message || "Couldn't save the scan on this device");
|
||||
setErrorModal(errorMessage(e) || "Couldn't save the scan on this device");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -251,21 +287,22 @@ export default function TicketScanningPage() {
|
||||
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
|
||||
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
|
||||
}`;
|
||||
await apiFetch<any>(url, {
|
||||
await apiFetch(url, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
qty,
|
||||
deviceId: offline.deviceId(),
|
||||
...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}),
|
||||
...(ticket?.session ? { sessionId: ticket.session.id } : {}),
|
||||
...(overrideConsent ? { overrideConsent: true } : {}),
|
||||
},
|
||||
});
|
||||
refreshStatsAndRecent();
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setSuccessModal(null);
|
||||
setScanInfo(null);
|
||||
const raw = e?.message || "Failed to scan ticket";
|
||||
const raw = errorMessage(e) || "Failed to scan ticket";
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const msg: string | undefined = parsed?.message;
|
||||
@@ -280,28 +317,27 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecent = async () => {
|
||||
const loadRecent = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const list = await apiFetch<any[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
|
||||
const list = await apiFetch<RecentScan[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
|
||||
setRecentScans(list);
|
||||
} catch {}
|
||||
};
|
||||
const loadStats = async () => {
|
||||
}, [token]);
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingStats(true);
|
||||
const s = await apiFetch<any>(`/api/tickets/scans/stats`, { authToken: token });
|
||||
const s = await apiFetch<ScanStats>(`/api/tickets/scans/stats`, { authToken: token });
|
||||
setStats(s);
|
||||
} catch {} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
const refreshStatsAndRecent = () => { loadRecent(); loadStats(); };
|
||||
}, [token]);
|
||||
const refreshStatsAndRecent = useCallback(() => { loadRecent(); loadStats(); }, [loadRecent, loadStats]);
|
||||
useEffect(() => {
|
||||
refreshStatsAndRecent();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
}, [refreshStatsAndRecent]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-4">
|
||||
@@ -312,9 +348,11 @@ export default function TicketScanningPage() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<QrCode className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Ticket Scanning</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Ticket Scanning</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Use the button to start/stop scanning. The back camera will be used when available.
|
||||
@@ -359,6 +397,23 @@ export default function TicketScanningPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventSessions.length > 0 && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-3">
|
||||
<label className="text-sm text-gray-700 shrink-0">Session</label>
|
||||
<select
|
||||
className="border rounded px-3 py-2 w-full sm:w-72 max-w-full"
|
||||
value={selectedSessionId}
|
||||
onChange={(e) => setSelectedSessionId(e.target.value)}
|
||||
disabled={!!offline.lock}
|
||||
>
|
||||
<option value="auto">Today's session{currentSessionLabel(selectedEvent) ? ` (${currentSessionLabel(selectedEvent)})` : ""}</option>
|
||||
{eventSessions.map(s => (
|
||||
<option key={s.id} value={s.id}>{sessionLabel(s, selectedEvent?.sessions)} — {formatSessionWhen(s)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OfflineScanPanel
|
||||
offline={offline}
|
||||
token={token}
|
||||
@@ -403,11 +458,11 @@ export default function TicketScanningPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats?.byStaff?.length > 0 && (
|
||||
{stats && stats.byStaff?.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{stats.byStaff.map((s: any) => (
|
||||
{stats.byStaff.map((s) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
@@ -419,7 +474,7 @@ export default function TicketScanningPage() {
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2">Last 10 scans</h3>
|
||||
<ul className="text-sm space-y-2 max-h-80 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
{recentScans.map((u) => (
|
||||
<li key={u.id} className="border rounded p-2 bg-white">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || 'Event'}</div>
|
||||
@@ -448,6 +503,7 @@ export default function TicketScanningPage() {
|
||||
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
|
||||
<div><span className="font-medium">Ticket type:</span> {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}</div>
|
||||
<div><span className="font-medium">Event:</span> {confirmModal.ticket?.event?.title || '—'}</div>
|
||||
{confirmModal.ticket?.session && <div><span className="font-medium">Session:</span> {confirmModal.ticket.session.label}</div>}
|
||||
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.attendee?.name || confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
|
||||
{confirmModal.ticket?.attendee?.name && confirmModal.ticket?.user?.name && (
|
||||
<div className="text-xs text-gray-500">Booked by {confirmModal.ticket.user.name}</div>
|
||||
@@ -533,8 +589,8 @@ export default function TicketScanningPage() {
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Usages:</div>
|
||||
<ul className="list-disc ml-5 mt-1 max-h-32 overflow-auto">
|
||||
{alreadyUsedModal.ticket.usages.map((u: any) => (
|
||||
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{u.quantityRedeemed > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
|
||||
{alreadyUsedModal.ticket.usages.map((u) => (
|
||||
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{(u.quantityRedeemed ?? 1) > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,24 @@ import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
|
||||
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
|
||||
type Person = { id: string; name: string; gender: string | null; dateOfBirth: string | null; registrationId: string; bookedBy: string };
|
||||
// GET /api/allocations/sets/:id/lists — medical/emergency only when ?medical=true and consent was given.
|
||||
type ListMember = {
|
||||
groupId: string; id: string; name: string; gender: string | null; dateOfBirth: string | null;
|
||||
phone: string | null; parentName: string | null; parentPhone: string | null; bookedBy: string; bookedByPhone: string | null;
|
||||
medical?: Record<string, unknown> | null; emergency?: Record<string, unknown> | null;
|
||||
};
|
||||
type SetLists = {
|
||||
set: { id: string; name: string };
|
||||
event: { id: string; title: string; startDate: string };
|
||||
groups: { id: string; name: string; capacity: number | null; gender: string | null; leader: string | null; members: ListMember[] }[];
|
||||
};
|
||||
type Board = { event: { id: string; title: string; startDate: string; collectGender: boolean; requiresParentalConsent: boolean }; sets: AllocSet[]; attendees: Person[]; leaders: { id: string; name: string }[]; unnamedPlaces: number };
|
||||
|
||||
const UNASSIGNED = "unassigned";
|
||||
@@ -67,7 +81,7 @@ export default function AllocationsPage() {
|
||||
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
|
||||
useEffect(() => { if (!loading && !user) router.replace("/login"); }, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [setId, setSetId] = useState("");
|
||||
@@ -80,7 +94,7 @@ export default function AllocationsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const upcoming = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 7 * 864e5)
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
@@ -96,8 +110,8 @@ export default function AllocationsPage() {
|
||||
const b = await apiFetch<Board>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
setBoard(b);
|
||||
setSetId(id => (b.sets.some(s => s.id === id) ? id : b.sets[0]?.id || ""));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load allocations");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load allocations");
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
@@ -126,8 +140,8 @@ export default function AllocationsPage() {
|
||||
}));
|
||||
try {
|
||||
await call(`/api/allocations/sets/${current.id}/assignments`, "PUT", { attendeeId, groupId });
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't move them");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't move them");
|
||||
load();
|
||||
}
|
||||
};
|
||||
@@ -138,8 +152,8 @@ export default function AllocationsPage() {
|
||||
const msg = await fn();
|
||||
if (msg) setInfo(msg);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -160,19 +174,19 @@ export default function AllocationsPage() {
|
||||
const printLists = async (master: boolean, medical: boolean) => {
|
||||
if (!current || !board) return;
|
||||
try {
|
||||
const data = await apiFetch<any>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
|
||||
const data = await apiFetch<SetLists>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
const row = (m: any) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
|
||||
const row = (m: ListMember) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
|
||||
+ `<td>${esc(m.phone || m.parentPhone || m.bookedByPhone || "")}</td><td>${esc(m.parentName || m.bookedBy || "")}</td>`
|
||||
+ (medical ? `<td>${esc(m.medical ? Object.values(m.medical).filter(Boolean).join("; ") : "")}</td><td>${esc(m.emergency ? Object.values(m.emergency).filter(Boolean).join(", ") : "")}</td>` : "") + `</tr>`;
|
||||
const head = `<tr><th>Name</th><th>Gender</th><th>Age</th><th>Phone</th><th>Parent / booked by</th>${medical ? "<th>Medical</th><th>Emergency contact</th>" : ""}</tr>`;
|
||||
let body = "";
|
||||
if (master) {
|
||||
const all = data.groups.flatMap((g: any) => g.members.map((m: any) => ({ ...m, group: g.name }))).sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m: any) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
|
||||
const all = data.groups.flatMap((g) => g.members.map((m) => ({ ...m, group: g.name }))).sort((a, b) => a.name.localeCompare(b.name));
|
||||
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
|
||||
} else {
|
||||
body = data.groups.map((g: any) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
|
||||
body = data.groups.map((g) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
|
||||
+ `<p>${esc(data.set.name)}${g.leader ? ` · Leader: ${esc(g.leader)}` : ""} · ${g.members.length}${g.capacity ? ` / ${g.capacity}` : ""} people</p>`
|
||||
+ `<table>${head}${g.members.map(row).join("")}</table></section>`).join("");
|
||||
}
|
||||
@@ -182,8 +196,8 @@ export default function AllocationsPage() {
|
||||
section{page-break-after:always} section:last-child{page-break-after:auto}
|
||||
</style></head><body>${body}<script>window.onload=function(){window.print()}</script></body></html>`);
|
||||
w.document.close();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't load the lists");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't load the lists");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,7 +208,7 @@ export default function AllocationsPage() {
|
||||
<BedDouble className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Rooms & groups</h1>
|
||||
<p className="text-sm text-gray-500">Put attendees into rooms, small groups, teams or buses</p>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ClipboardCheck, Check, Download, FileSpreadsheet, FileText, Send, Award, Search, Ban } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { fetchApiFile, saveBlob } from "@/lib/export";
|
||||
import type { AttendanceRegister, AttendanceRow, Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type Filter = "all" | "completed" | "not_completed" | "missed_recent";
|
||||
|
||||
const colDate = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short" });
|
||||
|
||||
function AttendancePageInner() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const canView = ["admin", "supervisor", "staff"].includes(user?.role || "");
|
||||
const canManage = user?.role === "admin" || user?.role === "supervisor";
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState(search.get("eventId") || "");
|
||||
const [reg, setReg] = useState<AttendanceRegister | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [showCancelled, setShowCancelled] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
// Multi-day events first (that's what the register is mostly for), newest first.
|
||||
const list = (evs || []).filter(e => e.requiresRegistration !== false)
|
||||
.sort((a, b) => Number(b.scheduleType === "sessions") - Number(a.scheduleType === "sessions") || new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
setEvents(list);
|
||||
if (!eventId && list.length) setEventId(list[0].id);
|
||||
})
|
||||
.catch(e => setError(errorMessage(e) || "Failed to load events"));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, canView]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setReg(await apiFetch<AttendanceRegister>(`/api/attendance/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load the register");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const columns = useMemo(() => (reg?.columns || []).filter(c => showCancelled || !c.cancelled), [reg, showCancelled]);
|
||||
const multi = reg?.event.scheduleType === "sessions";
|
||||
// Sessions already held (for "missed the last two").
|
||||
const heldIds = useMemo(() => (reg?.columns || []).filter(c => !c.cancelled && new Date(c.startsAt).getTime() < Date.now()).map(c => c.id), [reg]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (reg?.rows || []).filter(r => {
|
||||
if (q && ![r.name, r.bookedBy, r.email, r.phone].some(v => v?.toLowerCase().includes(q))) return false;
|
||||
if (filter === "completed") return r.completed;
|
||||
if (filter === "not_completed") return !r.completed;
|
||||
if (filter === "missed_recent") {
|
||||
const lastTwo = heldIds.slice(-2);
|
||||
return lastTwo.length > 0 && lastTwo.every(id => !r.checkIns[id]);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [reg, query, filter, heldIds]);
|
||||
|
||||
const toggle = async (row: AttendanceRow, sessionId: string) => {
|
||||
if (!canManage || !token) return;
|
||||
const present = !row.checkIns[sessionId];
|
||||
const key = `${row.ticketId}:${sessionId}`;
|
||||
setPending(key);
|
||||
try {
|
||||
// Picked sessions have a ticket per session.
|
||||
const ticketId = row.sessionTickets?.[sessionId] || row.ticketId;
|
||||
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId, sessionId, present } });
|
||||
const col = reg?.columns.find(c => c.id === sessionId);
|
||||
setInfo(`${row.name || "Attendee"} marked ${present ? "present" : "absent"}${multi && col ? ` for ${col.label}` : ""}.`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't update attendance");
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const post = async (path: string, body: object, done: (r: Record<string, number>) => string) => {
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await apiFetch<Record<string, number>>(path, { method: "POST", authToken: token, body });
|
||||
setInfo(done(r));
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async (path: string, filename: string) => {
|
||||
if (!token) return;
|
||||
try {
|
||||
saveBlob(await fetchApiFile(API_BASE, token, path), filename);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Download failed");
|
||||
}
|
||||
};
|
||||
|
||||
const safeTitle = (reg?.event.title || "event").replace(/[^a-z0-9 -]/gi, "").slice(0, 60);
|
||||
const certsOn = !!reg?.event.certificatesEnabled;
|
||||
const issued = (reg?.rows || []).filter(r => r.certificate && !r.certificate.revokedAt).length;
|
||||
const unsent = (reg?.rows || []).filter(r => r.certificate && !r.certificate.revokedAt && !r.certificate.emailedAt).length;
|
||||
const completed = (reg?.rows || []).filter(r => r.completed).length;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<ClipboardCheck className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Attendance</h1>
|
||||
<p className="text-sm text-gray-500">Who came to each session, and completion certificates</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need staff access to view attendance.</div>}
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
|
||||
|
||||
{canView && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem] max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No events</option>}
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))}{ev.scheduleType === "sessions" ? ` · ${(ev.sessions || []).filter(s => !s.cancelled).length} sessions` : ""})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input className="border rounded-lg pl-8 pr-3 py-2 text-sm w-56" placeholder="Search name, email, phone" value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</div>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm" value={filter} onChange={e => setFilter(e.target.value as Filter)}>
|
||||
<option value="all">Everyone</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="not_completed">Not completed</option>
|
||||
{multi && <option value="missed_recent">Missed the last two sessions</option>}
|
||||
</select>
|
||||
{multi && (reg?.columns || []).some(c => c.cancelled) && (
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600">
|
||||
<input type="checkbox" checked={showCancelled} onChange={e => setShowCancelled(e.target.checked)} /> Show cancelled sessions
|
||||
</label>
|
||||
)}
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=xlsx`, `Attendance - ${safeTitle}.xlsx`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileSpreadsheet className="w-4 h-4" /> Excel</button>
|
||||
<button type="button" disabled={!reg} onClick={() => download(`/api/attendance/${eventId}/export?format=pdf`, `Attendance - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border bg-white hover:bg-gray-50 disabled:opacity-50"><FileText className="w-4 h-4" /> PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reg && (
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 rounded bg-gray-100 text-gray-700">{reg.rows.length} attendee{reg.rows.length === 1 ? "" : "s"}</span>
|
||||
<span className="px-2 py-1 rounded bg-green-50 text-green-800">{completed} completed</span>
|
||||
{certsOn && <span className="px-2 py-1 rounded bg-brand-50 text-brand-800">{issued} certificate{issued === 1 ? "" : "s"} issued</span>}
|
||||
{!multi && <span className="px-2 py-1 rounded bg-gray-50 text-gray-500">Single-date event — one check-in column</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reg && certsOn && canManage && (
|
||||
<div className="flex flex-wrap items-center gap-2 p-3 rounded-lg bg-brand-50/50 border border-brand-100">
|
||||
<Award className="w-4 h-4 text-brand-600" />
|
||||
<span className="text-sm text-gray-700 mr-auto">Certificates are issued automatically when the event ends. You can also:</span>
|
||||
<button type="button" disabled={busy} onClick={() => post(`/api/attendance/${eventId}/certificates`, {}, r => `${r.issued} certificate${r.issued === 1 ? "" : "s"} issued.`)} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Issue now</button>
|
||||
<button type="button" disabled={busy || unsent === 0} onClick={() => post(`/api/attendance/${eventId}/certificates/send`, {}, r => `${r.sent} certificate${r.sent === 1 ? "" : "s"} sent${r.skipped ? `, ${r.skipped} couldn't be sent` : ""}.`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send unsent ({unsent})</button>
|
||||
<button type="button" disabled={issued === 0} onClick={() => download(`/api/attendance/${eventId}/certificates.pdf`, `Certificates - ${safeTitle}.pdf`)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Download className="w-3.5 h-3.5" /> Download all</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{busy && !reg && <p className="text-sm text-gray-500">Loading…</p>}
|
||||
{reg && rows.length === 0 && <p className="text-sm text-gray-500 py-6 text-center">{reg.rows.length === 0 ? "No tickets for this event yet." : "Nobody matches."}</p>}
|
||||
|
||||
{reg && rows.length > 0 && (
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-xs text-gray-600">
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-gray-50 text-left font-medium px-3 py-2 min-w-[12rem]">Name</th>
|
||||
{columns.map(c => (
|
||||
<th key={c.id} className={`font-medium px-2 py-2 text-center whitespace-nowrap ${c.cancelled ? "text-gray-400 line-through" : ""}`}>
|
||||
<div>{c.label}</div>
|
||||
<div className="text-[10px] font-normal text-gray-400">{multi ? colDate.format(new Date(c.startsAt)) : ""}</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="font-medium px-2 py-2 text-center">Total</th>
|
||||
{certsOn && <th className="font-medium px-3 py-2 text-left">Certificate</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rows.map(r => (
|
||||
<tr key={r.ticketId} className="hover:bg-gray-50/60">
|
||||
<td className="sticky left-0 z-10 bg-white px-3 py-2">
|
||||
<div className="font-medium text-gray-900">{r.name || "—"}{r.quantity > 1 && <span className="ml-1 text-xs text-gray-500">×{r.quantity}</span>}</div>
|
||||
<div className="text-[11px] text-gray-500">{r.variant ? `${r.option} — ${r.variant}` : r.option}{r.bookedBy ? ` · booked by ${r.bookedBy}` : ""}</div>
|
||||
</td>
|
||||
{columns.map(c => {
|
||||
const qty = r.checkIns[c.id] || 0;
|
||||
const key = `${r.ticketId}:${c.id}`;
|
||||
if (r.bookedSessions && !r.bookedSessions.includes(c.id)) {
|
||||
return <td key={c.id} className="px-2 py-2 text-center text-gray-300" title="Not booked for this session">—</td>;
|
||||
}
|
||||
const disabled = !canManage || c.cancelled || pending !== null || reg.event.cashupStatus === "closed";
|
||||
return (
|
||||
<td key={c.id} className="px-2 py-2 text-center">
|
||||
<button type="button" disabled={disabled} onClick={() => toggle(r, c.id)}
|
||||
title={canManage ? (qty ? "Present — click to mark absent" : "Absent — click to mark present") : undefined}
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border text-xs ${qty ? "bg-green-50 border-green-300 text-green-700" : "bg-white border-gray-200 text-gray-300"} ${disabled ? "cursor-default" : "hover:border-brand-400"} ${pending === key ? "opacity-50" : ""}`}>
|
||||
{qty ? (r.quantity > 1 ? qty : <Check className="w-4 h-4" />) : "·"}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-2 py-2 text-center">
|
||||
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${r.completed ? "bg-green-50 text-green-800" : "text-gray-600"}`}>{r.attended}/{r.total}</span>
|
||||
</td>
|
||||
{certsOn && (
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{r.certificate && !r.certificate.revokedAt ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono text-gray-700">{r.certificate.code}</span>
|
||||
<button type="button" className="text-brand-700 hover:underline" onClick={() => download(`/api/attendance/certificates/${r.certificate!.id}/pdf`, `Certificate - ${r.name}.pdf`)}>PDF</button>
|
||||
{canManage && <button type="button" className="text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates/send`, { certificateId: r.certificate!.id }, x => (x.sent ? `Certificate sent to ${r.name}.` : `Couldn't send — ${r.name} has no email or WhatsApp on file.`))}>{r.certificate.emailedAt ? "Resend" : "Send"}</button>}
|
||||
{canManage && <button type="button" className="text-red-600 hover:underline inline-flex items-center gap-0.5" onClick={() => post(`/api/attendance/certificates/${r.certificate!.id}/revoke`, {}, () => `Certificate for ${r.name} revoked.`)}><Ban className="w-3 h-3" />Revoke</button>}
|
||||
</div>
|
||||
) : canManage ? (
|
||||
<button type="button" className="text-xs text-brand-700 hover:underline" onClick={() => post(`/api/attendance/${eventId}/certificates`, { ticketId: r.ticketId }, () => `Certificate issued to ${r.name}.`)}>
|
||||
{r.certificate?.revokedAt ? "Re-issue" : r.completed ? "Issue" : "Issue anyway"}
|
||||
</button>
|
||||
) : <span className="text-xs text-gray-400">—</span>}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 text-xs text-gray-600">
|
||||
<tr>
|
||||
<td className="sticky left-0 bg-gray-50 px-3 py-2 font-medium">Present</td>
|
||||
{columns.map(c => <td key={c.id} className="px-2 py-2 text-center font-medium">{c.cancelled ? "—" : reg.totals[c.id] || 0}</td>)}
|
||||
<td />
|
||||
{certsOn && <td />}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{reg && canManage && (
|
||||
<p className="text-[11px] text-gray-400">Click a box to mark someone present or absent (e.g. they were let in without a scan). Changes are recorded in the audit log.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AttendancePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AttendancePageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import type { BundleGroupPricing, BundleKind, EventBundle } from "@/types";
|
||||
|
||||
type OptionLite = { id: string; name: string; price: number; variants?: { id: string; name: string; price?: number | null }[] };
|
||||
type EventLite = { id: string; title: string; startDate: string; endDate?: string; eventOptions: OptionLite[] };
|
||||
|
||||
type ItemDraft = { eventOptionId: string; variantId: string | null; quantity: string };
|
||||
type Draft = {
|
||||
name: string;
|
||||
description: string;
|
||||
kind: BundleKind;
|
||||
price: string;
|
||||
setSize: string;
|
||||
freeCount: string;
|
||||
groupPricing: BundleGroupPricing;
|
||||
items: ItemDraft[];
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const blankDraft = (options: OptionLite[]): Draft => ({
|
||||
name: "", description: "", kind: "exact", price: "", setSize: "4", freeCount: "1", groupPricing: "per_ticket",
|
||||
items: options.slice(0, 2).map(o => ({ eventOptionId: o.id, variantId: null, quantity: "1" })),
|
||||
validFrom: "", validUntil: "", isActive: true,
|
||||
});
|
||||
|
||||
// <input type="datetime-local"> wants local "YYYY-MM-DDTHH:mm".
|
||||
const toLocalInput = (iso: string | null | undefined) => {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
const draftFromBundle = (b: EventBundle): Draft => ({
|
||||
name: b.name, description: b.description || "", kind: b.kind || "exact",
|
||||
price: b.kind === "free" ? "" : String(b.price),
|
||||
setSize: b.setSize ? String(b.setSize) : "4", freeCount: b.freeCount ? String(b.freeCount) : "1",
|
||||
groupPricing: b.groupPricing || "per_ticket",
|
||||
items: b.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId || null, quantity: String(i.quantity) })),
|
||||
validFrom: toLocalInput(b.validFrom), validUntil: toLocalInput(b.validUntil), isActive: b.isActive,
|
||||
});
|
||||
|
||||
const fmtDate = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
|
||||
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
// fieldCls has no width, for fields sized by their row (inputCls is full width).
|
||||
const fieldCls = "border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
const inputCls = `w-full ${fieldCls}`;
|
||||
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
|
||||
|
||||
const KIND_INFO: Record<BundleKind, { label: string; hint: string; example: string }> = {
|
||||
exact: { label: "Exact set", hint: "These tickets in these numbers", example: "2 adults + 3 kids for R220" },
|
||||
mix: { label: "Pick any", hint: "Any mix of the chosen tickets", example: "Any 4 tickets for R250" },
|
||||
free: { label: "Buy X get Y free", hint: "Cheapest ones in each set are free", example: "Buy 3, get 1 free" },
|
||||
group: { label: "Group discount", hint: "A lower price from a group size up", example: "10+ tickets: R70 each" },
|
||||
};
|
||||
|
||||
/** Standard price of one unit of an item (variant price, else the option's). */
|
||||
function unitPrice(options: OptionLite[], item: { eventOptionId: string; variantId: string | null }) {
|
||||
const o = options.find(x => x.id === item.eventOptionId);
|
||||
if (!o) return 0;
|
||||
const v = item.variantId ? (o.variants || []).find(x => x.id === item.variantId) : null;
|
||||
return Number(v && v.price !== null && v.price !== undefined ? v.price : o.price) || 0;
|
||||
}
|
||||
|
||||
function bundleStatus(b: EventBundle): { label: string; cls: string } {
|
||||
const now = Date.now();
|
||||
if (!b.isActive) return { label: "Off", cls: "bg-gray-100 text-gray-600" };
|
||||
if (b.validUntil && new Date(b.validUntil).getTime() < now) return { label: "Ended", cls: "bg-gray-100 text-gray-600" };
|
||||
if (b.validFrom && new Date(b.validFrom).getTime() > now) return { label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
|
||||
return { label: "On sale", cls: "bg-green-100 text-green-800" };
|
||||
}
|
||||
|
||||
export default function BundlesPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [allEvents, setAllEvents] = useState<EventLite[]>([]);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [bundles, setBundles] = useState<EventBundle[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(list => setAllEvents(list || []))
|
||||
.catch(e => setError(errorMessage(e) || "Failed to load events"));
|
||||
}, [token, canView, setError]);
|
||||
|
||||
// Upcoming events soonest first; past ones (when shown) after them, most recent first.
|
||||
const events = useMemo(() => {
|
||||
const upcoming = allEvents.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
const past = allEvents.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
return showPast ? [...upcoming, ...past] : upcoming;
|
||||
}, [allEvents, showPast]);
|
||||
|
||||
// Keep the picked event while it's in the list; otherwise start on the next upcoming one.
|
||||
useEffect(() => {
|
||||
if (!events.some(e => e.id === eventId)) setEventId(events[0]?.id || "");
|
||||
}, [events, eventId]);
|
||||
|
||||
const event = events.find(e => e.id === eventId) || null;
|
||||
const options = event?.eventOptions || [];
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) { setBundles([]); return; }
|
||||
setFetching(true);
|
||||
try {
|
||||
setBundles(await apiFetch<EventBundle[]>(`/api/bundles/event/${eventId}`, { authToken: token }) || []);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load bundles");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const toggleActive = async (b: EventBundle) => {
|
||||
try {
|
||||
await apiFetch(`/api/bundles/${b.id}`, { method: "PUT", authToken: token!, body: { isActive: !b.isActive } });
|
||||
setInfo(`${b.name} turned ${b.isActive ? "off" : "on"}.`);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to update bundle");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (b: EventBundle) => {
|
||||
if (!window.confirm(`Delete "${b.name}"?`)) return;
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/bundles/${b.id}`, { method: "DELETE", authToken: token! });
|
||||
setInfo(res?.message || "Bundle deleted");
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to delete bundle");
|
||||
}
|
||||
};
|
||||
|
||||
const startNew = () => setEditing({ id: null, draft: blankDraft(options) });
|
||||
const canAdd = !!event && options.length > 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Gift className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Bundle deals</h1>
|
||||
<p className="text-sm text-gray-500">Tickets sold together for one price</p>
|
||||
</div>
|
||||
</div>
|
||||
{canView && (
|
||||
<button
|
||||
onClick={startNew}
|
||||
disabled={!canAdd}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50 w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> New bundle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor access to manage bundle deals.</div>
|
||||
)}
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
|
||||
|
||||
{canView && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3 mb-4">
|
||||
<div className="w-full sm:w-auto">
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm w-full sm:min-w-[20rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">{showPast ? "No events" : "No upcoming events"}</option>}
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title} ({fmtDate(ev.startDate)}){isPast(ev) ? " · past" : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 sm:pb-2">
|
||||
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{event && options.length === 0 && (
|
||||
<p className="text-sm text-gray-500 py-6 text-center">This event has no tickets yet. Add ticket options to the event first.</p>
|
||||
)}
|
||||
|
||||
{event && options.length > 0 && !fetching && bundles.length === 0 && (
|
||||
<div className="text-center py-10">
|
||||
<div className="w-12 h-12 rounded-full bg-emerald-50 flex items-center justify-center mx-auto mb-3">
|
||||
<Gift className="w-6 h-6 text-emerald-600" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">No bundle deals for this event yet</p>
|
||||
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">e.g. "Family deal: 2 adults + 3 kids for R220". Buyers get it automatically when their tickets match.</p>
|
||||
<button onClick={startNew} className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
|
||||
<Plus className="w-4 h-4" /> Create a bundle
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bundles.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{bundles.map(b => (
|
||||
<BundleCard
|
||||
key={b.id}
|
||||
bundle={b}
|
||||
onEdit={() => setEditing({ id: b.id, draft: draftFromBundle(b) })}
|
||||
onToggle={() => toggleActive(b)}
|
||||
onDelete={() => remove(b)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Bundles apply automatically, as many times as someone's tickets allow, and only when cheaper than the tickets at the prices
|
||||
actually charged (early-bird and staff prices included). Coupon codes come off afterwards.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && event && (
|
||||
<BundleModal
|
||||
token={token!}
|
||||
event={event}
|
||||
bundleId={editing.id}
|
||||
initial={editing.draft}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={(msg) => { setEditing(null); setInfo(msg); load(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BundleCard({ bundle: b, onEdit, onToggle, onDelete }: { bundle: EventBundle; onEdit: () => void; onToggle: () => void; onDelete: () => void }) {
|
||||
const status = bundleStatus(b);
|
||||
const kind = b.kind || "exact";
|
||||
const dates = b.validFrom || b.validUntil
|
||||
? `${b.validFrom ? fmtDate(b.validFrom) : "Now"} – ${b.validUntil ? fmtDate(b.validUntil) : "no end date"}`
|
||||
: null;
|
||||
return (
|
||||
<div className={`border rounded-xl p-4 flex flex-col gap-3 ${b.isActive ? "bg-white" : "bg-gray-50"}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-gray-900 break-words">{b.name}</div>
|
||||
{b.description && <div className="text-xs text-gray-500 mt-0.5 break-words">{b.description}</div>}
|
||||
</div>
|
||||
<span className={`shrink-0 text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs font-medium text-brand-700">{KIND_INFO[kind].label}{kind === "exact" ? "" : ` · ${kind === "group" ? "qualifying" : "any of"}:`}</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{b.items.map(i => (
|
||||
<span key={i.id || `${i.eventOptionId}-${i.variantId}`} className="inline-flex items-center gap-1 rounded-full bg-emerald-50 text-emerald-800 border border-emerald-100 px-2.5 py-1 text-xs">
|
||||
{kind === "exact" && <span className="font-semibold">{i.quantity} ×</span>} {i.eventOption?.name || "Ticket"}{i.variant ? ` (${i.variant.name})` : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
|
||||
{kind === "exact" || kind === "mix" ? (
|
||||
<>
|
||||
<span className="text-xl font-semibold text-gray-900">{fmtRand(b.price)}</span>
|
||||
<span className="text-sm text-gray-500">{kind === "mix" ? `for any ${b.setSize}` : "for the set"}</span>
|
||||
{b.standardValue !== null && <span className="text-sm text-gray-400 line-through">{fmtRand(b.standardValue)}</span>}
|
||||
</>
|
||||
) : kind === "free" ? (
|
||||
<span className="text-xl font-semibold text-gray-900">Buy {(b.setSize || 0) - (b.freeCount || 0)}, get {b.freeCount} free</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xl font-semibold text-gray-900">{b.groupPricing === "percent" ? `${b.price}% off` : `${fmtRand(b.price)} each`}</span>
|
||||
<span className="text-sm text-gray-500">from {b.setSize} tickets</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-xs font-medium ${b.savingText || kind === "free" || (kind === "group" && b.groupPricing === "percent") ? "text-green-700" : "text-amber-700"}`}>
|
||||
{b.savingText || (kind === "free" || (kind === "group" && b.groupPricing === "percent") ? "" : "No saving at the standard prices")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600 space-y-0.5">
|
||||
<div>
|
||||
Used <strong>{b.uses || 0}×</strong> on {b.registrations || 0} registration{b.registrations === 1 ? "" : "s"} · {fmtRand(b.totalDiscount || 0)} given
|
||||
</div>
|
||||
{dates && <div className="flex items-center gap-1"><CalendarClock className="w-3.5 h-3.5" /> {dates}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1 border-t border-gray-100 mt-auto">
|
||||
<button onClick={onEdit} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
|
||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
<button onClick={onToggle} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border mt-2 ${b.isActive ? "text-amber-700 border-amber-200 hover:bg-amber-50" : "text-green-700 border-green-200 hover:bg-green-50"}`}>
|
||||
<Power className="w-3.5 h-3.5" /> {b.isActive ? "Turn off" : "Turn on"}
|
||||
</button>
|
||||
<button onClick={onDelete} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border text-red-700 border-red-200 hover:bg-red-50 mt-2 sm:ml-auto">
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
|
||||
token: string;
|
||||
event: EventLite;
|
||||
bundleId: string | null;
|
||||
initial: Draft;
|
||||
onClose: () => void;
|
||||
onSaved: (message: string) => void;
|
||||
}) {
|
||||
const [d, setD] = useState<Draft>(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
|
||||
const options = event.eventOptions || [];
|
||||
|
||||
const setItem = (i: number, patch: Partial<ItemDraft>) => set("items", d.items.map((it, j) => (j === i ? { ...it, ...patch } : it)));
|
||||
const exact = d.kind === "exact";
|
||||
const price = Number(d.price) || 0;
|
||||
const size = Math.max(0, Math.floor(Number(d.setSize) || 0));
|
||||
const free = Math.max(0, Math.floor(Number(d.freeCount) || 0));
|
||||
const dearest = d.items.reduce((m, it) => Math.max(m, unitPrice(options, it)), 0);
|
||||
// Exact: what the set costs separately. Pick any: the dearest possible set.
|
||||
const standardValue = exact ? d.items.reduce((s, it) => s + unitPrice(options, it) * (Number(it.quantity) || 0), 0) : dearest * size;
|
||||
const saving_ = standardValue - price;
|
||||
const priceLabel = exact ? "Bundle price (R)" : d.kind === "mix" ? "Price for the set (R)" : d.groupPricing === "percent" ? "Percentage off (%)" : "Price per ticket (R)";
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name: d.name.trim(),
|
||||
description: d.description.trim() || null,
|
||||
kind: d.kind,
|
||||
price: d.kind === "free" ? 0 : Number(d.price),
|
||||
setSize: exact ? null : Number(d.setSize),
|
||||
freeCount: d.kind === "free" ? Number(d.freeCount) : null,
|
||||
groupPricing: d.kind === "group" ? d.groupPricing : null,
|
||||
items: d.items.map(it => ({ eventOptionId: it.eventOptionId, variantId: it.variantId, quantity: exact ? Number(it.quantity) : 1 })),
|
||||
validFrom: d.validFrom ? new Date(d.validFrom).toISOString() : null,
|
||||
validUntil: d.validUntil ? new Date(d.validUntil).toISOString() : null,
|
||||
isActive: d.isActive,
|
||||
};
|
||||
if (bundleId) {
|
||||
await apiFetch(`/api/bundles/${bundleId}`, { method: "PUT", authToken: token, body });
|
||||
onSaved(`${body.name} saved.`);
|
||||
} else {
|
||||
await apiFetch(`/api/bundles/event/${event.id}`, { method: "POST", authToken: token, body });
|
||||
onSaved(`${body.name} created.`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center sm:p-4" onClick={onClose}>
|
||||
<form onSubmit={save} className="bg-white w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between gap-3 p-4 border-b">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold">{bundleId ? "Edit bundle" : "New bundle deal"}</h2>
|
||||
<p className="text-xs text-gray-500 truncate">{event.title}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close" className="p-1 -m-1"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-5 overflow-y-auto overflow-x-hidden overscroll-contain">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input className={inputCls} value={d.name} onChange={e => set("name", e.target.value)} placeholder="Family deal" required maxLength={80} />
|
||||
<p className="text-xs text-gray-500 mt-1">Shown to buyers and on receipts.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description <span className="font-normal text-gray-400">(optional)</span></label>
|
||||
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Bring the whole family" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Type of deal</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.keys(KIND_INFO) as BundleKind[]).map(k => (
|
||||
<button type="button" key={k} onClick={() => set("kind", k)} aria-pressed={d.kind === k}
|
||||
className={`text-left rounded-lg border p-2.5 transition-colors ${d.kind === k ? "border-brand-500 bg-brand-50 ring-1 ring-brand-500" : "hover:bg-gray-50"}`}>
|
||||
<div className="text-sm font-medium text-gray-900">{KIND_INFO[k].label}</div>
|
||||
<div className="text-xs text-gray-500">{KIND_INFO[k].hint}</div>
|
||||
<div className="text-[11px] text-gray-400 mt-0.5 hidden sm:block">e.g. {KIND_INFO[k].example}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{exact ? "Tickets in the bundle" : "Qualifying tickets"}</label>
|
||||
{!exact && <p className="text-xs text-gray-500 -mt-1 mb-2">Any of these count towards the deal.</p>}
|
||||
<div className="space-y-2">
|
||||
{d.items.map((it, i) => {
|
||||
const opt = options.find(o => o.id === it.eventOptionId);
|
||||
const hasVariants = (opt?.variants || []).length > 0;
|
||||
return (
|
||||
<div key={i} className="rounded-lg border bg-gray-50 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{exact && (
|
||||
<>
|
||||
<input type="number" min="1" step="1" inputMode="numeric" className={`${fieldCls} w-16 shrink-0 px-2 bg-white text-center`} value={it.quantity} onChange={e => setItem(i, { quantity: e.target.value })} aria-label="Quantity" required />
|
||||
<span className="text-sm text-gray-500 shrink-0">×</span>
|
||||
</>
|
||||
)}
|
||||
<select className={`${fieldCls} min-w-0 flex-1 bg-white`} value={it.eventOptionId} onChange={e => setItem(i, { eventOptionId: e.target.value, variantId: null })} aria-label="Ticket">
|
||||
{options.map(o => <option key={o.id} value={o.id}>{o.name} ({fmtRand(o.price)})</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={() => set("items", d.items.filter((_, j) => j !== i))} disabled={d.items.length <= 1}
|
||||
className="p-2 shrink-0 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 disabled:opacity-30 disabled:hover:bg-transparent" aria-label="Remove ticket">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{hasVariants && (
|
||||
<select className={`${inputCls} mt-2 bg-white`} value={it.variantId || ""} onChange={e => setItem(i, { variantId: e.target.value || null })} aria-label="Variant">
|
||||
<option value="">Any {opt?.name.toLowerCase()} variant</option>
|
||||
{(opt?.variants || []).map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button type="button" onClick={() => set("items", [...d.items, { eventOptionId: options[0]?.id || "", variantId: null, quantity: "1" }])}
|
||||
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-brand-700 hover:underline">
|
||||
<Plus className="w-4 h-4" /> Add a ticket type
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!exact && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{d.kind === "group" ? "Minimum tickets" : "Tickets per set"}</label>
|
||||
<input type="number" min={d.kind === "free" ? 2 : 2} step="1" inputMode="numeric" className={inputCls} value={d.setSize} onChange={e => set("setSize", e.target.value)} required />
|
||||
</div>
|
||||
{d.kind === "free" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Free in each set</label>
|
||||
<input type="number" min="1" step="1" inputMode="numeric" className={inputCls} value={d.freeCount} onChange={e => set("freeCount", e.target.value)} required />
|
||||
</div>
|
||||
)}
|
||||
{d.kind === "group" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Group price</label>
|
||||
<select className={inputCls} value={d.groupPricing} onChange={e => set("groupPricing", e.target.value as BundleGroupPricing)}>
|
||||
<option value="per_ticket">Price per ticket</option>
|
||||
<option value="percent">Percentage off</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.kind === "free" ? (
|
||||
<div className="rounded-lg bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||
{size > free && free > 0
|
||||
? <>Buy {size - free}, get {free} free. In every {size} qualifying tickets, the cheapest {free === 1 ? "one is" : `${free} are`} free.</>
|
||||
: <span className="text-amber-700">The set needs more tickets than are free.</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{priceLabel}</label>
|
||||
<input type="number" min="0" max={d.kind === "group" && d.groupPricing === "percent" ? 100 : undefined} step="0.01" inputMode="decimal" className={`${inputCls} sm:max-w-[12rem]`} value={d.price} onChange={e => set("price", e.target.value)} required />
|
||||
{d.kind === "group" ? (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
{size >= 2 ? `From ${size} qualifying tickets, every one of them ` : "Every qualifying ticket "}
|
||||
{d.groupPricing === "percent" ? `gets ${d.price || "…"}% off.` : `costs ${d.price === "" ? "…" : fmtRand(price)} (tickets already cheaper stay as they are).`}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg bg-gray-50 p-2">
|
||||
<div className="text-[11px] uppercase tracking-wide text-gray-500">{exact ? "Separately" : "Dearest set"}</div>
|
||||
<div className="text-sm font-semibold text-gray-900">{fmtRand(standardValue)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-2">
|
||||
<div className="text-[11px] uppercase tracking-wide text-gray-500">Bundle</div>
|
||||
<div className="text-sm font-semibold text-gray-900">{d.price === "" ? "—" : fmtRand(price)}</div>
|
||||
</div>
|
||||
<div className={`rounded-lg p-2 ${d.price !== "" && saving_ <= 0 ? "bg-amber-50" : "bg-emerald-50"}`}>
|
||||
<div className="text-[11px] uppercase tracking-wide text-gray-500">Saving</div>
|
||||
<div className={`text-sm font-semibold ${d.price !== "" && saving_ <= 0 ? "text-amber-700" : "text-emerald-700"}`}>{d.price === "" ? "—" : fmtRand(Math.max(0, saving_))}</div>
|
||||
</div>
|
||||
</div>
|
||||
{d.price !== "" && saving_ <= 0 && <p className="text-xs text-amber-700 mt-1">The bundle isn't cheaper than the standard prices, so it won't apply unless prices change.</p>}
|
||||
{!exact && <p className="text-xs text-gray-500 mt-1">The saving depends on which tickets are picked; it only applies when the set price is cheaper.</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Available from <span className="font-normal text-gray-400">(optional)</span></label>
|
||||
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Available until <span className="font-normal text-gray-400">(optional)</span></label>
|
||||
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" className="mt-0.5" checked={d.isActive} onChange={e => set("isActive", e.target.checked)} />
|
||||
<span>On sale <span className="block text-xs text-gray-500">Registrations that already have this bundle keep it if you turn it off later.</span></span>
|
||||
</label>
|
||||
|
||||
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 p-4 border-t">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{saving ? "Saving…" : bundleId ? "Save changes" : "Create bundle"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user