Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eee10b610 | ||
|
|
7b57fa4d04 | ||
|
|
d265047c84 | ||
|
|
40c5c9cc1d | ||
|
|
261bc8a639 | ||
|
|
594cb35a85 | ||
|
|
be5ad620da | ||
|
|
96adf20cf0 | ||
|
|
a9c7a1129b | ||
|
|
d8eb9f22cc | ||
|
|
4cae0fc672 | ||
|
|
cd33ba281e | ||
|
|
220dafb400 | ||
|
|
eb0f390215 | ||
|
|
55a7ee2970 | ||
|
|
4bc7dbc989 | ||
|
|
71b9da2458 | ||
|
|
654f51b86d | ||
|
|
aeb5d6d678 | ||
|
|
f2b51dbd88 | ||
|
|
25e3e9d3a6 | ||
|
|
e7bed08a35 | ||
|
|
ede1ddbe00 | ||
|
|
8933a1fe77 | ||
|
|
30e5db82df | ||
|
|
f99b84858e | ||
|
|
7c3f4694e2 | ||
|
|
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 |
@@ -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'"
|
||||
+9
-1
@@ -31,6 +31,9 @@ yarn-error.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# this install's PM2 config (made from ecosystem.config.example.js; deploys never touch it)
|
||||
/ecosystem.config.js
|
||||
|
||||
# misc scratch / generated files
|
||||
temp/
|
||||
backend/public/uploads/
|
||||
@@ -39,4 +42,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/
|
||||
|
||||
+320
@@ -7,6 +7,326 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.39.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **POPIA toolkit, part 2: the admin side.** A new **Admin → Privacy (POPIA)** page with four tabs:
|
||||
- **Privacy notice editor.** The privacy notice is now editable. It starts from the built-in text (updated for 1.38.0's privacy page, news opt-in, unsubscribe and STOP), one box per section; add, remove and reorder sections, save drafts, preview, and **publish numbered versions**. Placeholders such as `{org}`, `{ioName}`, `{ioEmail}`, `{medicalRetentionDays}` and `{operators}` are filled in when the notice is shown, so it follows Site settings and the operator register. Earlier versions stay readable on the privacy page (`/legal/privacy?version=N`).
|
||||
- **Re-accepting the notice.** When publishing, tick "Significant change" (and say what changed) to ask everyone to accept the new version: they're asked once, after their next login, with a link to delete their account instead. Sign-up records which version was accepted. Acceptances are counted per version.
|
||||
- **Operators** — the register of outside services that handle personal information (Yoco, PayFast, WAWP, WhatsApp, email and hosting providers, Google Maps, Sentry), with what each receives and where. Those in use are listed in the notice.
|
||||
- **Consent log** — every news opt-in/opt-out (and how), every privacy notice acceptance, and every parental consent answer, searchable by person.
|
||||
- **Access log** — who downloaded, emailed or opened personal information in bulk: report PDFs/Excel files (and reports emailed or WhatsApped), attendance registers, certificate downloads, room/group lists, attendee form answers, the parental consent and medical lists, database backups, and every "view as" session. With how many records each time.
|
||||
- The contact page shows the Information Officer (when set in Site settings → Legal), with a link to the privacy notice.
|
||||
- "Download my data" includes the privacy notice versions you accepted.
|
||||
|
||||
### Changed
|
||||
|
||||
- Deleting someone's personal data also removes the IP addresses kept with their consent and notice records (the records stay as proof).
|
||||
|
||||
## [1.38.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **POPIA toolkit, part 1: privacy for the people in the system.**
|
||||
- **Privacy page** (My account → Privacy): choose News & announcements by email and/or WhatsApp, see the history of those choices, **download my data** (a readable PDF, or the complete copy as JSON: account, registrations and the people registered, form answers, parental consent, payments, tickets and check-ins, certificates, feedback, communication choices), **ask for a correction**, and **delete my account**.
|
||||
- **Two kinds of bulk message.** When emailing or WhatsApping attendees, or sending a broadcast, choose **Event update** (about an event the recipients registered for — goes to everyone selected) or **News & announcements** (only to people who opted in, with an unsubscribe link). Attendee messages default to event updates, broadcasts to news, and the next-event promo automation is always news. Previews say how many people were skipped.
|
||||
- **Unsubscribe**: every news email ends with an unsubscribe link and carries one-click unsubscribe headers (Gmail, Apple Mail); every event update says why it was sent and links to the preferences. News WhatsApps end with "Reply STOP" and a link. Unsubscribed addresses without an account are remembered too.
|
||||
- **WhatsApp STOP**: replying STOP (or "unsubscribe") to the church's number stops WhatsApp news and sends a confirmation. Needs WAWP's webhook pointed at the URL shown in Site settings → WhatsApp (it carries a secret, since WAWP doesn't sign webhooks).
|
||||
- **News opt-in**: an unticked "news and announcements" box on sign-up. Existing accounts stay opted in (logged as "existing account") and are asked once, after their next login, to confirm or opt out. Every change is logged with when and how.
|
||||
- **Data requests** (Admin → Data requests): correction and deletion requests with a 30-day due date, emailed to the Information Officer and admins, with a daily reminder for ones due within a week or overdue (`privacy` job, 08:00; `PRIVACY_JOBS_ENABLED=false` to switch off). Completing or rejecting a request emails the person and is in the audit log.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Deleting an account is now a request.** "Delete my account" (and "Also delete my personal data" when closing an account) closes the account straight away; the personal information is deleted when an admin completes the request — after any registration for an upcoming event has been cancelled or the event is over.
|
||||
- **Deleting personal data now goes much further.** Before, it only cleared the account's name, email and phone. Now it also removes the names and details of the people they registered (and their own details on other people's bookings), form answers, parental-consent details, sponsor names, feedback authorship, pending transfer requests, certificate names, login history, and payer names/emails kept from Yoco and PayFast notifications. Payments, registrations, tickets and check-ins are kept, as "Deleted User", for the books and reports. Admin → Users → Anonymise uses the same deletion, and is refused while the person has a registration for an upcoming event.
|
||||
- "Download my data" is blocked while an admin is viewing as someone else.
|
||||
|
||||
## [1.37.4] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Deploy script** (`scripts/deploy.sh`): without a tag it now deploys the latest `main`. The PM2 config is per install: `ecosystem.config.js` is no longer in git (the repo has `ecosystem.config.example.js`), so deploys stop overwriting the install's app names and port; the script keeps the install's copy across checkouts and rollbacks, and creates it from the example on a fresh install. The script also runs from a temporary copy (so checking out a new version of it mid-deploy is safe), and a failure rolls back once.
|
||||
|
||||
## [1.37.3] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- The production build (and so the deploy) failed on unescaped quotes in the Payments help text.
|
||||
|
||||
## [1.37.2] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Payment status checks** for when a provider's notification never arrives. Payments → Reconcile has a new "Waiting for payment" list: online checkouts from the last 14 days with no payment yet, each with "Check status" (and "Check all"). For PayFast it looks the payment up in PayFast's transaction history and, if PayFast received it, records it exactly as the notification would have (same amount check, tickets and emails). A background job does this every 15 minutes for checkouts between 10 minutes and 3 days old (`payment-status`, switch off with `PAYMENT_STATUS_JOB_ENABLED=false`). Yoco's API has no way to look a payment up, so Yoco checkouts show "can't check" — find them in the Yoco portal and record them with Reconcile, as before.
|
||||
- **Test connection** on the PayFast settings card: checks the saved merchant ID and passphrase with PayFast.
|
||||
|
||||
## [1.37.1] - 2026-10-23
|
||||
|
||||
### Changed
|
||||
|
||||
- **No "Pay online" when it can't be used.** When no payment provider is switched on, or the amount is under the R15 minimum, the registration success page, My Events ("Make payment" and "Make donation"), the donate page, "Sponsor a spot", the donation box on the register form and the supervisor Payment Link tab no longer offer online payment. Where it helps, they say why and to pay at the event instead.
|
||||
- Roadmap: SnapScan and Zapper are dropped (Yoco and PayFast are enough); later releases move up two versions — POPIA toolkit is next as 1.38.0.
|
||||
|
||||
## [1.37.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **PayFast.** A second online payment provider (card, instant EFT and more). Switch it on in Site settings → Payments, then enter the merchant ID, merchant key and salt passphrase from your PayFast (or Sandbox) dashboard and choose Sandbox or Live. Payments are confirmed by PayFast's notification (ITN) at `/api/webhooks/payfast`, which is checked the way PayFast requires: the signature, that it came from PayFast, that PayFast confirms it, and that the amount matches the checkout. PayFast starts switched off.
|
||||
- **Choosing how to pay.** When more than one provider is on, payers choose on a new "Pay online" page (`/pay/…`). Payment links in emails, WhatsApp and invoices go there too, so they work for every provider.
|
||||
- Payer-facing wording no longer says Yoco: "Pay online" after registering, a neutral redirect message, and the Terms and Privacy pages name both providers (Yoco or PayFast), including that PayFast may receive your name and email to fill in its payment page.
|
||||
- A payment of a different amount from the checkout (e.g. changed along the way) isn't recorded automatically: it waits in "Unreconciled Online Payments" with a note explaining why.
|
||||
|
||||
## [1.36.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Site settings → Payments** (replaces the Yoco tab): turn each online payment provider on or off and set their order. The first one enabled and set up is used by default. Yoco's keys and webhook settings are on the same tab. Yoco is the only provider for now; PayFast, SnapScan and Zapper follow in the next releases. If no provider is on and set up, online payments are off and people pay at the door.
|
||||
|
||||
### Changed
|
||||
|
||||
- Online payments go through one payment provider layer (`backend/src/payments`). Yoco works exactly as before: the same checkout, the same webhook URL (`/api/webhooks/yoco`) and the same reconciliation.
|
||||
- **Online payments must be at least R15**, for every kind of online payment (balance, part-payment, donation, sponsorship). A smaller balance is paid at the door.
|
||||
- Payments record which provider took them (`provider`, e.g. "yoco"); existing online payments are marked as Yoco. The payment method (card, Apple Pay…) is unchanged, so cashups and reports add up as before. Provider fees are still never deducted or added — they're handled at cashup.
|
||||
- Yoco transactions are now "provider transactions" (`/api/provider-transactions`, with `/api/yoco-transactions` still working). The Payments page's reconcile list is called "Unreconciled Online Payments" and shows the provider. The database table is renamed in place, so stored transactions are kept.
|
||||
- The checkout endpoint is now `POST /api/payments/checkout` (with an optional `provider`); `/api/payments/yoco-checkout` still works.
|
||||
|
||||
## [1.35.1] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Waitlist per session.** On pick-sessions events, a full session shows "Join waitlist" in the session list, and the waitlist works per session: when a place in that session frees up, the next person waiting for it gets the offer. Offers never run past the session's start, and nobody is offered a session that has started or was cancelled. The claim link ticks the session for them. My Events, the claim page and the supervisor Waitlist page show which session an entry is for.
|
||||
- **"All sessions" tickets on pick-sessions events let people choose their sessions.** When an event's "How people book" is "Pick sessions", every ticket now shows the session list. A ticket set to "All sessions" is still one ticket at one price, whatever is ticked. The ticket then only scans on the ticked sessions (online and offline), and the attendance register, certificates, session reminders and session capacity follow them. Tickets and the ticket email list the ticked dates. A ticket set to "Pick sessions" is still charged per session. Events booked as "The whole course" show no session list. Bookings made before this cover every session, as before.
|
||||
- **Session picker at the door.** The At the door "Select items" pop-up now has the session picker for per-session tickets, so walk-ins can be booked straight in. For a new attendee, a place in today's session is selected by default when there's room.
|
||||
|
||||
### Changed
|
||||
|
||||
- Per-session tickets show their list of sessions straight away (on the registration page, Manual registration, At the door and the self-service kiosk). Before, the list only appeared after pressing "+", so it wasn't obvious you could choose sessions. Ticking a session sets the quantity to 1.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Per-session stock limits on pick-sessions tickets weren't being enforced, and the public event page lost all stock information on events with such a ticket.
|
||||
- The offline-scanning service worker served the app's script files from its cache first, for good. On a development server, where those files keep their names when they change, a browser that had opened the scanner kept running old code on every page, even after a hard reload. It now fetches them from the network and uses the cache only when offline. Its cache is renamed, so old copies are cleared.
|
||||
- At the door, editing a registration that has per-session tickets could no longer be saved ("below the quantity already issued").
|
||||
- Whole-course tickets: the waitlist now also counts room in sessions that have a capacity, so it no longer says "places available" when a session is full. Places held for waitlist offers now count towards session capacity.
|
||||
|
||||
## [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
|
||||
|
||||
+164
-27
@@ -30,10 +30,11 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
|
||||
14. [Reports](#reports)
|
||||
15. [Site settings & setup wizard](#site-settings--setup-wizard)
|
||||
16. [Accounts & security](#accounts--security)
|
||||
17. [Help system](#help-system)
|
||||
18. [Background jobs](#background-jobs)
|
||||
19. [Operations](#operations)
|
||||
20. [Page map](#page-map)
|
||||
17. [Privacy (POPIA)](#privacy-popia)
|
||||
18. [Help system](#help-system)
|
||||
19. [Background jobs](#background-jobs)
|
||||
20. [Operations](#operations)
|
||||
21. [Page map](#page-map)
|
||||
|
||||
---
|
||||
|
||||
@@ -56,7 +57,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 +80,46 @@ 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. "All sessions" tickets on such an event also show
|
||||
the sessions: people tick the ones they'll come to and pay the ticket's one price; the
|
||||
ticket scans, reminds and counts towards capacity for those sessions only. The waitlist
|
||||
works per session (a full session shows "Join waitlist"). Bookable from the registration page, Manual registration,
|
||||
At the door (today's session is preselected) and the kiosk. 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.
|
||||
@@ -140,7 +175,8 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
emailed/WhatsApped a claim link and the places are held for them (24 hours by
|
||||
default) so nobody else can buy them. Unclaimed offers expire and move on; people
|
||||
who asked for more than came free can be offered what's free or skipped. Claiming
|
||||
works without logging in. People can leave from My Events.
|
||||
works without logging in. People can leave from My Events. On pick-sessions events
|
||||
people wait for a particular session; offers never run past the session's start.
|
||||
- **Parental consent (youth events)** — on events that require it, every attendee
|
||||
gives a date of birth; for anyone under 18, a parent or guardian gets an email and
|
||||
a WhatsApp with a consent link. They read the event's consent text, optionally give
|
||||
@@ -165,7 +201,17 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
|
||||
- **Online card payments via Yoco** — checkout for the full outstanding balance,
|
||||
confirmed by a signed webhook. Keys and the webhook are set up from
|
||||
Site Settings → Yoco.
|
||||
Site Settings → Payments, where providers are switched on or off and put in order
|
||||
(the first one is the default). Online payments are at least R15; payments are
|
||||
recorded at the full amount and provider fees are dealt with at cashup.
|
||||
- **PayFast** — the second online provider (card, instant EFT…), with sandbox and live
|
||||
modes. When several providers are on, payers choose on the "Pay online" page; a
|
||||
payment of a different amount from the checkout is held for review instead of being
|
||||
recorded. When no provider is on (or the amount is under R15), nothing offers to pay
|
||||
online.
|
||||
- **Payment status checks** — if a provider's notification never arrives, Payments →
|
||||
Reconcile → "Waiting for payment" checks with the provider (PayFast; Yoco's API can't be
|
||||
asked) and records what it finds. A job checks recent PayFast checkouts every 15 minutes.
|
||||
- **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.
|
||||
@@ -216,7 +262,7 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
Supervisors can **assign** a donation (fully or partly) to someone's
|
||||
registration and **unassign** it later. Assignments are kept as separate
|
||||
records, so the original donation never changes.
|
||||
- **Yoco reconciliation** — imported Yoco transactions can be matched to a
|
||||
- **Online payment reconciliation** — provider (Yoco) transactions can be matched to a
|
||||
registration, recorded as a donation (optionally choosing the donor), or
|
||||
ignored.
|
||||
- **Invoices & receipts** — branded PDFs: an invoice (itemised, early-bird
|
||||
@@ -240,6 +286,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
|
||||
@@ -289,6 +344,8 @@ Every user picks a **notification preference**: Email, WhatsApp, or Both.
|
||||
outstanding balance).
|
||||
- **Broadcasts** — ad-hoc email/WhatsApp to any users or addresses, not tied to
|
||||
one event.
|
||||
- **Message type** — Event update or News & announcements; news only reaches people
|
||||
who opted in (see [Privacy](#privacy-popia)).
|
||||
- **Automations** — schedule a sequence of lifecycle emails for an event
|
||||
(pre-event, final reminder, thank-you, promo for another event).
|
||||
- **Manage scheduled** — view, edit or delete queued jobs per channel. Sends
|
||||
@@ -344,7 +401,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
|
||||
@@ -390,6 +447,8 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
- **Manage users** — server-side search and filters (role, active), paging,
|
||||
edit role and notification preference, revoke sessions, delete or
|
||||
**anonymise** personal data.
|
||||
- **Data requests** — POPIA correction and deletion requests (see
|
||||
[Privacy](#privacy-popia)).
|
||||
- **Registrations** — all registrations, with filters (event, status, text),
|
||||
status counts, revenue and outstanding totals, and per-registration options,
|
||||
form answers and payments (with who recorded each one).
|
||||
@@ -457,7 +516,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 |
|
||||
| Payments | Online payment providers: Enabled toggle and order; PayFast merchant ID/key, passphrase and sandbox/live; Yoco's 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.
|
||||
@@ -482,13 +541,68 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
|
||||
(`TRUST_PROXY` to read real IPs behind a proxy).
|
||||
- **Security event log** — logins, password changes and resets, with device
|
||||
and IP.
|
||||
- **Account closure** — deactivate, or anonymise personal data while keeping
|
||||
financial records.
|
||||
- **Account closure** — deactivate, or close and ask for the personal data to be
|
||||
deleted (see [Privacy](#privacy-popia)).
|
||||
- **Upload hardening** — server-generated filenames, and uploads served with a
|
||||
sandbox CSP and `nosniff`.
|
||||
|
||||
---
|
||||
|
||||
## Privacy (POPIA)
|
||||
|
||||
Tools that help a church handle personal information the way POPIA expects. They
|
||||
support compliance; wording and retention choices are for the church's Information
|
||||
Officer.
|
||||
|
||||
**For everyone** — the **Privacy** page (`/dashboard/user/privacy`):
|
||||
|
||||
- **News & announcements** opt-in per channel (email, WhatsApp), with the history of
|
||||
every choice: when, and how (sign-up, privacy page, unsubscribe link, STOP reply…).
|
||||
Sign-up has an unticked opt-in box; accounts that never answered are asked once
|
||||
after logging in.
|
||||
- **Download my data** — readable PDF, or the complete copy as JSON.
|
||||
- **Correct my data** — profile fields directly; anything else as a correction request.
|
||||
- **Delete my account** — closes the account at once and files a deletion request.
|
||||
|
||||
**Bulk messages** are either an **Event update** (about an event the recipients
|
||||
registered for; always sent) or **News & announcements** (only to people who opted in
|
||||
on that channel and haven't unsubscribed). News emails carry an unsubscribe link and
|
||||
one-click `List-Unsubscribe` headers; news WhatsApps end with "Reply STOP" and a link.
|
||||
Event updates say why they were sent and link to the preferences. A STOP reply on
|
||||
WhatsApp turns off WhatsApp news (through WAWP's `message` webhook, checked against a
|
||||
secret in the webhook URL).
|
||||
|
||||
**Privacy notice** (`/legal/privacy`) — edited in **Admin → Privacy (POPIA)**
|
||||
(`/dashboard/admin/privacy`): sections start from the built-in text; drafts, preview,
|
||||
and published, numbered versions (earlier ones stay readable with `?version=N`).
|
||||
Placeholders (`{org}`, `{ioName}`, `{ioEmail}`, `{website}`, `{orgEmail}`,
|
||||
`{medicalRetentionDays}`, `{operators}`) are filled in from Site settings and the
|
||||
operator register. A version marked as a significant change is accepted once by everyone
|
||||
after their next login; sign-up records the version accepted. The contact page shows the
|
||||
Information Officer.
|
||||
|
||||
**Registers and logs** (same admin page):
|
||||
|
||||
- **Operators** — the services that process personal information (payment, WhatsApp,
|
||||
email, hosting, maps, error monitoring), what each receives and where; those in use
|
||||
are listed in the notice.
|
||||
- **Consent log** — news opt-ins/opt-outs, privacy notice acceptances and parental consent
|
||||
answers, searchable.
|
||||
- **Access log** — every bulk export or view of personal information (reports, attendance
|
||||
registers, certificates, group lists, form answers, consent and medical lists, backups)
|
||||
and every "view as" session, with the number of records.
|
||||
|
||||
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
|
||||
deletion requests with a 30-day due date, emailed to the Information Officer and
|
||||
admins, reminded daily when due within a week. Completing a deletion anonymises the
|
||||
person: name, contact details, the people they registered, form answers, consent
|
||||
details, sponsor names, login history and payer details in provider notifications are
|
||||
removed; payments, registrations, tickets and check-ins stay as "Deleted User". It
|
||||
waits while they have a registration for an upcoming event. Everything is in the audit
|
||||
log.
|
||||
|
||||
---
|
||||
|
||||
## Help system
|
||||
|
||||
A floating **"Need help?"** button on every page opens a page-specific guide
|
||||
@@ -499,16 +613,27 @@ 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`) |
|
||||
| `payment-status` — look up PayFast checkouts (10 min – 3 days old) whose notification never arrived, and record them | Every 15 minutes (`PAYMENT_STATUS_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`) |
|
||||
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -520,7 +645,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).
|
||||
|
||||
---
|
||||
@@ -534,16 +668,19 @@ falling back to a general guide.
|
||||
| `/[alias]` | Public | Event short link |
|
||||
| `/register/[eventId]` | Public / User | Registration |
|
||||
| `/registration/success` | Public / User | Confirmation, form and pay |
|
||||
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Yoco return pages |
|
||||
| `/pay/[id]` | Public | Pay online: choose a provider, or go on to PayFast |
|
||||
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Return pages after paying online |
|
||||
| `/forms` | Public / User | Attendee forms |
|
||||
| `/contact`, `/legal/terms`, `/legal/privacy`, `/lockdown-rules` | Public | Info pages |
|
||||
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
|
||||
| `/setup` | First run | Setup wizard |
|
||||
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
|
||||
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
|
||||
| `/certificates/verify/[code]` | Public | Check a completion certificate |
|
||||
| `/unsubscribe?t=…` | Public | Unsubscribe from news (link in news emails/WhatsApps) |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
|
||||
| `/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`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -67,7 +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.
|
||||
- Yoco payments are set up in Admin → Site Settings → Payments: 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
|
||||
|
||||
@@ -81,8 +81,36 @@ 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, on the server in the repo folder:
|
||||
|
||||
- `bash scripts/deploy.sh` — the latest `main`
|
||||
- `bash scripts/deploy.sh v1.37.4` — a tagged release
|
||||
|
||||
It backs up the database, checks the version out, installs, migrates, builds and reloads PM2.
|
||||
If anything fails before the reload, it goes back to the previous version (the running app
|
||||
isn't touched).
|
||||
|
||||
PM2 uses the install's own `ecosystem.config.js` (app names, frontend port). It isn't in git,
|
||||
so deploys never change it; the first deploy creates it from `ecosystem.config.example.js` —
|
||||
set names that match your PM2 apps (`pm2 ls`) and the port you use.
|
||||
|
||||
### Backend Deployment
|
||||
|
||||
1. Provision a PostgreSQL database and set DATABASE_URL.
|
||||
@@ -138,7 +166,7 @@ Documentation: see Frontend README for environment and scripts.
|
||||
- **`OptionVariant` model**: event options can now have sub-variants (sizes, colours, ticket types — e.g. Adult/Child/VIP under a single "Ticket" option). Variants have an optional `price` override and their own `stockLimit`.
|
||||
- **Stock limits**: `stockLimit` added to `EventOption` and `EarlyBirdTier`. A value of `0` means unlimited. All stock counts exclude cancelled registrations.
|
||||
- **Early-bird concurrency handling**: once a Yoco checkout has been issued at an early-bird price, that price is honoured even if another user simultaneously exhausts the tier's stock. Stock-based forfeiture only applies at registration time (via `resolveOptionPrice`). Deadline expiry at payment time forfeits the price; stock races after the checkout is issued do not.
|
||||
- **Early-bird stock forfeiture**: if a tier's deadline passes after registration but before payment, `refreshPricingForRegistration` (called before every Yoco checkout) re-evaluates the tier and updates `priceSnapshot`. If prices changed, `POST /api/payments/yoco-checkout` returns `{ priceUpdated: true, newTotal }` (HTTP 200) so the frontend can show a warning before retrying.
|
||||
- **Early-bird stock forfeiture**: if a tier's deadline passes after registration but before payment, `refreshPricingForRegistration` (called before every Yoco checkout) re-evaluates the tier and updates `priceSnapshot`. If prices changed, `POST /api/payments/checkout` returns `{ priceUpdated: true, newTotal }` (HTTP 200) so the frontend can show a warning before retrying.
|
||||
- **`priceSnapshot` / `appliedTierId`** on `RegistrationOption`: price and tier are snapshotted at registration creation. `computeRegistrationTotalDue` uses `priceSnapshot` as authoritative when present (legacy rows without a snapshot fall back to deadline-only re-evaluation).
|
||||
- **Cancelled registration tickets**: `GET /api/tickets/mytickets` now excludes tickets from cancelled registrations at the database level.
|
||||
- **Cancellation payment guard**: `DELETE /api/registrations/:id` blocks non-admins from cancelling a registration that has positive payments. Admins can cancel at any time.
|
||||
|
||||
+18
-3
@@ -57,12 +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)
|
||||
## Payments
|
||||
|
||||
Managed in the Yoco tab (through `/api/yoco`, not the generic settings endpoints).
|
||||
Managed in the Payments tab (through `/api/payments/providers/settings` and `/api/yoco`, not the generic settings endpoints).
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `payfast_merchant_id` / `payfast_merchant_key` | `PAYFAST_MERCHANT_ID` / `PAYFAST_MERCHANT_KEY` env vars, else *(blank)* | From the PayFast (or Sandbox) dashboard. Both are needed before PayFast can take payments. The key isn't secret (it's in the payment form), so it's shown in full. Managed through `/api/payfast`. |
|
||||
| `payfast_passphrase` | `PAYFAST_PASSPHRASE` env var, else *(blank)* | Stored encrypted. Must match the dashboard's salt passphrase exactly, or PayFast refuses payments ("signature does not match"). |
|
||||
| `payfast_mode` | `PAYFAST_MODE` env var, else `sandbox` | `sandbox` or `live`: where payers are sent and where notifications are confirmed. |
|
||||
| `payment_providers` | Yoco enabled | JSON `{ order: [...], enabled: { yoco: true } }`: which online payment providers are on, and their order (the first one enabled and set up is the default). Providers not listed yet come last, switched off (Yoco on, as it was the only provider). If none is on and set up, online payments are off. |
|
||||
| `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. |
|
||||
@@ -75,7 +79,7 @@ Managed in the Yoco tab (through `/api/yoco`, not the generic settings endpoints
|
||||
| `legal_io_name` | `"[not yet configured]"` | Same reasoning as `legal_operator_name`. |
|
||||
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
|
||||
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. Only used by the built-in privacy notice: once a version is published in Admin → Privacy, its publish date is the effective date. |
|
||||
| `notify_user_on_impersonation` | `"false"` | When `"true"`, a user is emailed (with the reason) whenever an admin views their account with Impersonate. Set under Site Settings → Legal → Support access. |
|
||||
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
@@ -85,6 +89,17 @@ Managed in the Yoco tab (through `/api/yoco`, not the generic settings endpoints
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
|
||||
| `WAWP_WEBHOOK_SECRET` | Generated (random) the first time the WhatsApp tab is opened | Part of the webhook URL shown in Site Settings → WhatsApp (`/api/whatsapp/webhook?secret=…`). WAWP doesn't sign webhooks, so incoming messages (STOP replies) are only acted on when the secret matches. Not editable in the UI; delete the row to get a new one (then update the URL in WAWP). The URL uses `BACKEND_URL`. |
|
||||
|
||||
## Privacy (POPIA)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `legal_io_email` (Information Officer, Site Settings → Legal) | — | Also where new privacy requests and the daily "requests due" reminder are emailed, together with all admins. The `io@example.com` placeholder is skipped. |
|
||||
| `PRIVACY_JOBS_ENABLED` (env) | `true` | The daily 08:00 `privacy` job that reminds admins of requests due within a week or overdue. |
|
||||
| `privacy_notice_draft` | — | The unpublished draft from Admin → Privacy → Privacy notice (JSON). Published versions are in the `PrivacyNotice` table. With no published version the built-in notice (`backend/src/utils/privacyNotice.js`) is shown. The notice's org name, Information Officer, website, emails and medical retention days come from the settings above when it's shown. |
|
||||
|
||||
People's News & announcements opt-in is per account (`User.newsEmail`, `User.newsWhatsApp`), not a site setting. Unsubscribe links are signed with `JWT_SECRET` and don't expire — changing `JWT_SECRET` breaks links in emails already sent.
|
||||
|
||||
## Backups
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ FRONTEND_URL=http://localhost:3000
|
||||
# YOCO_SECRET_KEY=sk_test_...
|
||||
# YOCO_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# ─── Payments (PayFast) — optional fallbacks ──────────────────────────────────
|
||||
# Preferred: Admin → Site Settings → Payments → PayFast. Only used if the database has no value.
|
||||
# PAYFAST_MERCHANT_ID=
|
||||
# PAYFAST_MERCHANT_KEY=
|
||||
# PAYFAST_PASSPHRASE=
|
||||
# PAYFAST_MODE=sandbox
|
||||
# PayFast notifications (ITNs) must come from a PayFast address. Behind a reverse proxy, set
|
||||
# TRUST_PROXY so the real address is seen. Only for testing through a tunnel:
|
||||
# PAYFAST_SKIP_IP_CHECK=true
|
||||
|
||||
# ─── Public URLs ──────────────────────────────────────────────────────────────
|
||||
# Used to build links in emails (password reset, activation, etc.)
|
||||
APP_BASE_URL=https://your-frontend-domain.com
|
||||
@@ -59,6 +69,10 @@ CONSENT_JOBS_ENABLED=true
|
||||
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
|
||||
WAITLIST_JOB_ENABLED=true
|
||||
# WAITLIST_JOB_INTERVAL_MS=600000
|
||||
# Payments: every 15 minutes, look up PayFast checkouts whose notification never arrived
|
||||
PAYMENT_STATUS_JOB_ENABLED=true
|
||||
# Privacy (POPIA): daily 08:00 reminder to admins of data requests due within a week
|
||||
PRIVACY_JOBS_ENABLED=true
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
+156
-17
@@ -18,9 +18,11 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Event Options & Variants](#event-options--variants)
|
||||
- [Event Attachments](#event-attachments)
|
||||
- [Registrations](#registrations)
|
||||
- [Privacy (POPIA)](#privacy-popia)
|
||||
- [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 +30,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)
|
||||
@@ -39,7 +42,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Sections](#sections)
|
||||
- [Banner](#banner)
|
||||
- [Forms](#forms)
|
||||
- [Yoco Transactions](#yoco-transactions)
|
||||
- [Provider Transactions](#provider-transactions)
|
||||
- [WhatsApp Admin](#whatsapp-admin)
|
||||
- [Webhooks](#webhooks)
|
||||
- [Settings](#settings)
|
||||
@@ -285,6 +288,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 +349,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,16 +441,78 @@ 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. An `all` option in a pick-mode event may also send
|
||||
`sessionIds`: it stays one line and one ticket at one price, with the sessions kept in
|
||||
`RegistrationOption.sessionIds` (empty = every session). That ticket only scans on those
|
||||
sessions, and capacity, reminders, the register and certificates follow them; adding more
|
||||
of the same ticket to a registration merges the sessions. 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.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity |
|
||||
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, sessionId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity. `sessionId` is required for a pick-sessions option (one session per entry) and refused otherwise |
|
||||
| GET | `/api/waitlist/mine` | user+ | My active entries |
|
||||
| DELETE | `/api/waitlist/:id` | user+ | Leave (an open offer moves on) |
|
||||
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers |
|
||||
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers (with `sessionId`, `sessionName`, `lineName` for a per-session offer; `eventStart` is the session's start) |
|
||||
| GET | `/api/waitlist/events/:eventId` | staff+ | The queue for an event |
|
||||
| POST | `/api/waitlist/:id/offer` | supervisor+ | Offer now (`{ quantity? }`), even over the stock limit |
|
||||
| POST | `/api/waitlist/:id/move` | supervisor+ | `{ direction: "up" \| "down" }` |
|
||||
@@ -440,9 +521,10 @@ 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.
|
||||
- Pick-sessions options are waited for per session: free places are that session's (stock per session and session capacity), an offer expires no later than the session's start, and sessions that started or were cancelled aren't offered. For main tickets, free places also take session capacity into account, and held places count towards it.
|
||||
|
||||
## Parental consent
|
||||
|
||||
@@ -469,6 +551,47 @@ Check-in rules:
|
||||
- `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given.
|
||||
- Supervisors can pass `overrideConsent: true`, which is audited as `consent_override`.
|
||||
|
||||
## Privacy (POPIA)
|
||||
|
||||
The data subject's tools, unsubscribe links and the admin data-requests inbox (`src/utils/privacy.js`, `src/controllers/privacyController.js`).
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/privacy/me` | user | `{ news: { email, whatsapp }, newsAskedAt, canWhatsApp, notificationPreference, history, requests }` |
|
||||
| PUT | `/api/privacy/me/news` | user | `{ email?: bool, whatsapp?: bool, source?: "prompt" }` — change the news opt-in (WhatsApp needs a valid phone). Logged in `CommunicationConsent`; `source: "prompt"` is the one-time question after login |
|
||||
| GET | `/api/privacy/me/export` | user | Download my data as JSON (`?format=pdf` for a readable PDF). Recorded as a completed self-service `access` request. 403 while impersonating; rate-limited |
|
||||
| POST | `/api/privacy/me/requests` | user | `{ type: "correction", details }` or `{ type: "deletion", password, details? }`. Deletion closes the account at once (deactivated, sessions revoked, news off). 409 if one of that type is already open. Emails the Information Officer + admins |
|
||||
| GET | `/api/privacy/unsubscribe/:token` | public (signed token) | What the link is for: an account (masked email, news state) or an address |
|
||||
| POST | `/api/privacy/unsubscribe/:token` | public (signed token) | `{ channels?: ["email","whatsapp"] }` — turn news off (default both) |
|
||||
| POST | `/api/privacy/unsubscribe/:token/one-click` | public (signed token) | RFC 8058 one-click (from the `List-Unsubscribe` header) — email news off. GET redirects to `/unsubscribe` |
|
||||
| GET | `/api/privacy/requests?status=open\|closed\|all` | admin | Data requests (not self-service downloads), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended) |
|
||||
| POST | `/api/privacy/requests/:id/complete` | admin | `{ response? }` (required for corrections; emailed). Deletion: anonymises the account — 409 while it has blockers. Audited as `data_request_completed` (+ `user_anonymised`) |
|
||||
| POST | `/api/privacy/requests/:id/reject` | admin | `{ response, cancelled?: bool }` — reject (or mark withdrawn); a deletion reopens the account. Audited as `data_request_rejected` |
|
||||
|
||||
Privacy notice, operators and logs (`src/utils/privacyNotice.js`, `src/utils/accessLog.js`, `src/controllers/privacyAdminController.js`):
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/privacy/notice?version=N` | public | The notice (current, or version N) with placeholders filled in: `{ version, builtIn, current, effectiveDate, changeSummary, sections: [{ id, title, body }], versions }`. Version 0 = the built-in notice, only while nothing is published |
|
||||
| POST | `/api/privacy/me/notice/accept` | user | Accept the current version (recorded in `NoticeAcceptance`, `User.noticeVersionAccepted`). 403 while impersonating |
|
||||
| GET | `/api/privacy/notice/draft` | admin | Editor state: `sections` (raw, with placeholders), `rendered`, `isDraft`, `savedAt`, `versions` (with acceptance counts), `builtIn` (the built-in sections), `placeholders` |
|
||||
| PUT | `/api/privacy/notice/draft` | admin | `{ sections }` — save the draft (AppSetting `privacy_notice_draft`) |
|
||||
| DELETE | `/api/privacy/notice/draft` | admin | Discard the draft |
|
||||
| POST | `/api/privacy/notice/publish` | admin | `{ sections, changeSummary?, requiresAcceptance? }` — publish the next version and clear the draft. `requiresAcceptance` needs a `changeSummary`. Audited as `privacy_notice_published` |
|
||||
| GET / POST | `/api/privacy/operators` | admin | The operator register / add one `{ name, purpose, dataShared?, location?, website?, active? }` |
|
||||
| PUT / DELETE | `/api/privacy/operators/:id` | admin | Change / remove an operator (audited as `operator_*`) |
|
||||
| GET | `/api/privacy/consent-log?type=news\|notice\|parental&q=&page=` | admin | News opt-in changes, notice acceptances, or parental consent answers (50 per page) |
|
||||
| GET | `/api/privacy/access-log?action=&actorId=&from=&to=&page=` | admin | Bulk exports/views of personal information, with `actions` (labels) and `actors` for the filters |
|
||||
|
||||
Placeholders in notice sections: `{org}`, `{orgEmail}`, `{website}`, `{ioName}`, `{ioEmail}`, `{medicalRetentionDays}`, and `{operators}` on a line of its own (one bullet per active operator). Login and `GET /api/users/profile` return `noticePromptPending` — true when a version with `requiresAcceptance` is newer than the one the person accepted. Sign-up records an acceptance of the current version (source `signup`).
|
||||
|
||||
Access log actions (`DataAccessLog.action`): `report_pdf`, `report_excel`, `report_email`, `report_whatsapp` (`/api/reports/*`, with the row count), `attendance_export`, `certificates_download`, `group_lists` (or `medical_list` with `?medical=true`), `form_responses` (first page of a list), `consent_list`, `medical_list`, `backup_download`, `impersonation`.
|
||||
|
||||
Notes:
|
||||
- Requests are due 30 days after they're made. The `privacy` job (08:00, `PRIVACY_JOBS_ENABLED=false` to disable) emails the Information Officer (`legal_io_email`) and admins about open requests due within 7 days or overdue.
|
||||
- **Anonymising** (also `POST /api/users/:id/anonymize`) keeps payments, registrations, tickets and check-ins, and removes: the account's name/email/phone/password, the details of attendees on their registrations and their own attendee rows elsewhere, parental-consent details, form answers and drafts, feedback authorship and invites, waiting waitlist places, pending transfer requests, names on transfers and certificates, sponsor names/messages, login history, password resets and 2FA challenges, and payer fields in provider webhook payloads and checkouts.
|
||||
- Sign-up (`POST /api/users`) takes `newsOptIn: bool`. Login and `GET /api/users/profile` return `newsPromptPending` (never answered the opt-in question).
|
||||
|
||||
## Coupons
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
@@ -494,7 +617,15 @@ Options and variants also accept an optional `staffPrice`, charged automatically
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/payments` | supervisor+ | Record manual payment (cash/EFT) |
|
||||
| POST | `/api/payments/yoco-checkout` | user+ | Initiate Yoco checkout; re-evaluates early-bird pricing first — returns `{ priceUpdated: true, newTotal }` if prices changed |
|
||||
| POST | `/api/payments/checkout` | user+ | Start an online checkout (also `/api/payments/yoco-checkout`); `provider` optional (default: first enabled provider); at least R15; re-evaluates early-bird pricing first — returns `{ priceUpdated: true, newTotal }` if prices changed |
|
||||
| POST | `/api/payments/checkout/send` | supervisor+ | Send a generated payment link by email or WhatsApp (also `/api/payments/yoco-checkout/send`) |
|
||||
| GET | `/api/payments/providers` | public | Providers people can pay with now (enabled and set up), in order: `{ providers: [{ id, label }], minimumAmount: 15 }` |
|
||||
| GET | `/api/payments/providers/settings` | admin | Every provider with `enabled`, `configured` and `capabilities` (`refund`, `getTransaction`, `listTransactions`) |
|
||||
| GET | `/api/payments/pay/:id` | public (checkout id) | The payer's page for a checkout: `{ status: open\|paid, amount, description, provider, providers, cancelUrl, redirectUrl, form }`. `form` (`{ action, fields }`) is set once a form provider (PayFast) is chosen — the page posts it |
|
||||
| POST | `/api/payments/pay/:id` | public (checkout id) | `{ provider }` → starts the checkout with that provider: `{ provider, redirectUrl }` (Yoco) or `{ provider, form }` (PayFast). 409 when already paid |
|
||||
| GET | `/api/payments/checkouts/open` | supervisor+ | Online checkouts from the last 14 days with no payment yet: `{ checkouts: [{ id, provider, providerLabel, canCheck, amount, description, type, registrationId, payer, createdAt }], days }` |
|
||||
| POST | `/api/payments/checkouts/check` | supervisor+ | `{ ids? }` (default: every open one) → asks each provider and records payments it received: `{ results: { [checkoutId]: { status: paid\|recorded\|needs_review\|not_paid\|cant_check, note? } }, recorded, message }`. PayFast: transaction history (API); Yoco: `cant_check` |
|
||||
| PUT | `/api/payments/providers/settings` | admin | `{ providers: [{ id, enabled }] }` in display order. Audit logged (`payment_providers_updated`) |
|
||||
| GET | `/api/payments/mypayments` | user+ | Own payments |
|
||||
| GET | `/api/payments/:id` | user+ | Payment by ID |
|
||||
| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration |
|
||||
@@ -514,7 +645,7 @@ Options and variants also accept an optional `staffPrice`, charged automatically
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/payments/yoco-checkout
|
||||
### POST /api/payments/checkout
|
||||
```json
|
||||
{
|
||||
"registrationId": "reg-uuid-...",
|
||||
@@ -567,6 +698,8 @@ Returns: `{ redirectUrl }` or `{ priceUpdated: true, newTotal, message }` if ear
|
||||
|
||||
Dynamic placeholders: `{{name}}`, `{{event.title}}`, `{{event.start}}`, `{{event.link}}`, `{{balance}}`, `{{promo.title}}`, `{{promo.link}}`
|
||||
|
||||
**Message kind (POPIA).** Every bulk send — these broadcasts, WhatsApp broadcasts, and `POST /api/events/:id/email-attendees` / `whatsapp-attendees` (and their `/schedule`) — takes `kind`: `"update"` or `"news"`. Broadcasts default to `news`, attendee messages to `update`; `promoEventId` (the next-event promo automation) forces `news`, `template: "tickets"` forces `update`. `news` goes only to accounts opted in on that channel (`User.newsEmail` / `newsWhatsApp`) and skips addresses in `MessagingOptOut`; it gets an unsubscribe link (email: plus `List-Unsubscribe` / `List-Unsubscribe-Post` headers; WhatsApp: "Reply STOP"). `update` emails get a footer saying why they were sent. Responses (and dry runs) include `kind` and `skipped`. See [Privacy](#privacy-popia).
|
||||
|
||||
---
|
||||
|
||||
## WhatsApp Broadcasts
|
||||
@@ -663,16 +796,16 @@ Groups of event options shown as labelled sections on the registration page (e.g
|
||||
|
||||
---
|
||||
|
||||
## Yoco Transactions
|
||||
## Provider Transactions
|
||||
|
||||
Raw webhook events from Yoco. Used for reconciling payments that weren't matched automatically.
|
||||
Transactions payment providers sent by webhook (`ProviderTransaction`, one per provider + external id). Used for reconciling payments that weren't matched automatically. Each item has `provider`, `providerLabel` and the checkout `metadata`. Also served at `/api/yoco-transactions` (the older name).
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/yoco-transactions` | supervisor+ | All Yoco webhook transactions |
|
||||
| GET | `/api/yoco-transactions/unreconciled` | supervisor+ | Transactions not yet matched to a payment |
|
||||
| POST | `/api/yoco-transactions/:id/reconcile` | supervisor+ | Manually link transaction to a registration |
|
||||
| POST | `/api/yoco-transactions/:id/ignore` | supervisor+ | Mark transaction as intentionally ignored |
|
||||
| GET | `/api/provider-transactions` | supervisor+ | All transactions (`?provider=yoco&reconciled=&ignored=`) |
|
||||
| GET | `/api/provider-transactions/unreconciled` | supervisor+ | Transactions not yet matched to a payment (`?provider=`) |
|
||||
| POST | `/api/provider-transactions/:id/reconcile` | supervisor+ | Record it as a payment on a registration (`{ registrationId }`) or a donation (`{ eventId, userId? }`); the payment gets the transaction's provider |
|
||||
| POST | `/api/provider-transactions/:id/ignore` | supervisor+ | Mark transaction as intentionally ignored |
|
||||
|
||||
---
|
||||
|
||||
@@ -682,7 +815,7 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/whatsapp/config` | admin | Get current WAWP credentials |
|
||||
| GET | `/api/whatsapp/config` | admin | Get current WAWP credentials, and `webhookUrl` (with its secret) to paste into WAWP |
|
||||
| POST | `/api/whatsapp/config` | admin | Save WAWP credentials to DB |
|
||||
| GET | `/api/whatsapp/status` | admin | WAWP session status |
|
||||
| GET | `/api/whatsapp/qr` | admin | QR code for WhatsApp device linking |
|
||||
@@ -699,8 +832,10 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies `X-Yoco-Signature`, creates `Payment` + `YocoTransaction`, updates registration status, generates tickets |
|
||||
| POST | `/api/webhooks/whatsapp` | — | WAWP inbound event webhook |
|
||||
| POST | `/api/webhooks/payfast` | PayFast ITN | Form-encoded. Checked as PayFast requires: signature (every posted field + passphrase), sender is a PayFast host (`PAYFAST_SKIP_IP_CHECK=true` turns this off for tunnels; behind a proxy set `TRUST_PROXY`), PayFast answers VALID at `/eng/query/validate`, and `amount_gross` equals the checkout (otherwise held for review with a `note`). `COMPLETE` records the payment (`provider: "payfast"`) |
|
||||
| POST | `/api/webhooks/yoco` | HMAC-verified | Yoco payment webhook — verifies the `webhook-signature` header (src/payments/providers/yoco.js), creates `Payment` (`provider: "yoco"`) + `ProviderTransaction`, updates registration status, generates tickets (src/payments/webhook.js). Processed even when Yoco is switched off, so checkouts already started complete |
|
||||
| POST | `/api/whatsapp/webhook?secret=…` | secret in URL | WAWP events (the URL to paste into WAWP is on `GET /api/whatsapp/config` as `webhookUrl`). `session.status` FAILED → automatic session recovery (works without the secret). `message` → a STOP/unsubscribe reply turns off WhatsApp news for that number and sends a confirmation; only with a matching secret, since WAWP doesn't sign webhooks. Senders identified only by a WhatsApp LID (no phone number) are ignored |
|
||||
| POST | `/api/webhooks/whatsapp` | — | Old placeholder, does nothing — use `/api/whatsapp/webhook` |
|
||||
|
||||
---
|
||||
|
||||
@@ -720,7 +855,11 @@ For what each individual setting key defaults to when it's never been saved, see
|
||||
|
||||
## 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.
|
||||
Managed from Admin → Site Settings → Payments.
|
||||
|
||||
PayFast: `GET /api/payfast/config` (admin) → `{ merchantId, merchantKey, passphraseMasked, hasPassphrase, mode, notifyUrl, notifyUrlProblem, configured }`; `PUT /api/payfast/config` (admin) `{ merchantId?, merchantKey?, passphrase?, mode? }` ('' clears; the passphrase is stored encrypted; audit logged as `payfast_config_updated`); `POST /api/payfast/test` (admin) checks the saved settings with a signed PayFast API ping.
|
||||
|
||||
Every checkout is stored as a `ProviderCheckout` (amount, metadata, request): `POST /api/payments/checkout` returns `checkoutId` (ours) and a `redirectUrl` — the provider's page, or `/pay/<checkoutId>` when the payer must choose or a form has to be posted. Registrations keep that `checkoutId`; webhooks find the checkout by our id or the provider's. `YOCO_SECRET_KEY` / `YOCO_WEBHOOK_SECRET` in `.env` are fallbacks used only when the database has no value.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
|
||||
@@ -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.
|
||||
@@ -143,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
|
||||
|
||||
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.25.0",
|
||||
"version": "1.39.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,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';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "WaitlistEntry" ADD COLUMN "sessionId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_sessionId_idx" ON "WaitlistEntry"("sessionId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "RegistrationOption" ADD COLUMN "sessionIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Payment providers (1.36.0). YocoTransaction becomes ProviderTransaction: renamed in place
|
||||
-- so every stored transaction is kept (Prisma's generated plan drops and recreates it).
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'payment_providers_updated';
|
||||
|
||||
-- Rename YocoTransaction → ProviderTransaction
|
||||
ALTER TABLE "YocoTransaction" RENAME TO "ProviderTransaction";
|
||||
ALTER TABLE "ProviderTransaction" RENAME CONSTRAINT "YocoTransaction_pkey" TO "ProviderTransaction_pkey";
|
||||
ALTER TABLE "ProviderTransaction" RENAME CONSTRAINT "YocoTransaction_paymentId_fkey" TO "ProviderTransaction_paymentId_fkey";
|
||||
ALTER INDEX "YocoTransaction_paymentId_idx" RENAME TO "ProviderTransaction_paymentId_idx";
|
||||
|
||||
-- Which provider sent it; external ids are unique per provider
|
||||
ALTER TABLE "ProviderTransaction" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'yoco';
|
||||
DROP INDEX "YocoTransaction_externalId_key";
|
||||
CREATE UNIQUE INDEX "ProviderTransaction_provider_externalId_key" ON "ProviderTransaction"("provider", "externalId");
|
||||
|
||||
-- Payment.provider: every online payment so far came through Yoco (linked to a transaction,
|
||||
-- or the donation split off one)
|
||||
ALTER TABLE "Payment" ADD COLUMN "provider" TEXT;
|
||||
UPDATE "Payment" SET "provider" = 'yoco'
|
||||
WHERE "id" IN (SELECT "paymentId" FROM "ProviderTransaction" WHERE "paymentId" IS NOT NULL)
|
||||
OR "originalPaymentId" IN (SELECT "paymentId" FROM "ProviderTransaction" WHERE "paymentId" IS NOT NULL);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'payfast_config_updated';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProviderTransaction" ADD COLUMN "note" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProviderCheckout" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT,
|
||||
"externalId" TEXT,
|
||||
"redirectUrl" TEXT,
|
||||
"amountCents" INTEGER NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'ZAR',
|
||||
"metadata" JSONB NOT NULL,
|
||||
"request" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProviderCheckout_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProviderCheckout_provider_externalId_idx" ON "ProviderCheckout"("provider", "externalId");
|
||||
@@ -0,0 +1,102 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DataRequestType" AS ENUM ('access', 'correction', 'deletion');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DataRequestStatus" AS ENUM ('open', 'completed', 'rejected', 'cancelled');
|
||||
|
||||
-- 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 'data_request_completed';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'data_request_rejected';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'user_anonymised';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "newsAskedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "newsEmail" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "newsWhatsApp" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CommunicationConsent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"address" TEXT,
|
||||
"channel" TEXT NOT NULL,
|
||||
"granted" BOOLEAN NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CommunicationConsent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MessagingOptOut" (
|
||||
"id" TEXT NOT NULL,
|
||||
"channel" TEXT NOT NULL,
|
||||
"address" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MessagingOptOut_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DataRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "DataRequestType" NOT NULL,
|
||||
"status" "DataRequestStatus" NOT NULL DEFAULT 'open',
|
||||
"userId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"details" TEXT,
|
||||
"response" TEXT,
|
||||
"selfService" BOOLEAN NOT NULL DEFAULT false,
|
||||
"dueAt" TIMESTAMP(3) NOT NULL,
|
||||
"closedAt" TIMESTAMP(3),
|
||||
"handledById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DataRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CommunicationConsent_userId_createdAt_idx" ON "CommunicationConsent"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CommunicationConsent_address_idx" ON "CommunicationConsent"("address");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MessagingOptOut_channel_address_key" ON "MessagingOptOut"("channel", "address");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataRequest_status_dueAt_idx" ON "DataRequest"("status", "dueAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataRequest_userId_idx" ON "DataRequest"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommunicationConsent" ADD CONSTRAINT "CommunicationConsent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DataRequest" ADD CONSTRAINT "DataRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DataRequest" ADD CONSTRAINT "DataRequest_handledById_fkey" FOREIGN KEY ("handledById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Existing accounts: kept opted in to news (WhatsApp only when they have a phone number),
|
||||
-- logged as "existing_account", and asked once at their next login (newsAskedAt stays null).
|
||||
UPDATE "User" SET "newsEmail" = true, "newsWhatsApp" = ("phoneNumber" IS NOT NULL)
|
||||
WHERE "isActive" = true AND "email" NOT LIKE '%@deleted.invalid' AND "email" NOT LIKE '%@deleted.local' AND "email" NOT LIKE '%@guest.local';
|
||||
|
||||
INSERT INTO "CommunicationConsent" ("id", "userId", "channel", "granted", "source")
|
||||
SELECT gen_random_uuid()::text, "id", 'email', true, 'existing_account' FROM "User" WHERE "newsEmail" = true;
|
||||
|
||||
INSERT INTO "CommunicationConsent" ("id", "userId", "channel", "granted", "source")
|
||||
SELECT gen_random_uuid()::text, "id", 'whatsapp', true, 'existing_account' FROM "User" WHERE "newsWhatsApp" = true;
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
-- 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 'privacy_notice_published';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_created';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'operator_deleted';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "noticeVersionAccepted" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PrivacyNotice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"sections" JSONB NOT NULL,
|
||||
"changeSummary" TEXT,
|
||||
"requiresAcceptance" BOOLEAN NOT NULL DEFAULT false,
|
||||
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"publishedById" TEXT,
|
||||
|
||||
CONSTRAINT "PrivacyNotice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NoticeAcceptance" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"version" INTEGER NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "NoticeAcceptance_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Operator" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"purpose" TEXT NOT NULL,
|
||||
"dataShared" TEXT NOT NULL,
|
||||
"location" TEXT,
|
||||
"website" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Operator_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DataAccessLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"actorRole" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"what" TEXT NOT NULL,
|
||||
"eventId" TEXT,
|
||||
"records" INTEGER,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DataAccessLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PrivacyNotice_version_key" ON "PrivacyNotice"("version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoticeAcceptance_userId_createdAt_idx" ON "NoticeAcceptance"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoticeAcceptance_version_idx" ON "NoticeAcceptance"("version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataAccessLog_createdAt_idx" ON "DataAccessLog"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataAccessLog_actorId_createdAt_idx" ON "DataAccessLog"("actorId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DataAccessLog_action_createdAt_idx" ON "DataAccessLog"("action", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PrivacyNotice" ADD CONSTRAINT "PrivacyNotice_publishedById_fkey" FOREIGN KEY ("publishedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoticeAcceptance" ADD CONSTRAINT "NoticeAcceptance_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DataAccessLog" ADD CONSTRAINT "DataAccessLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- The operator register starts with the services Hope Events itself uses. Admins correct the
|
||||
-- details (e.g. which email and hosting provider) in Admin → Privacy → Operators.
|
||||
INSERT INTO "Operator" ("id", "name", "purpose", "dataShared", "location", "website", "active", "order", "updatedAt") VALUES
|
||||
(gen_random_uuid()::text, 'Yoco', 'Card payments', 'Payment amount and reference; card details are entered on Yoco''s own page', 'South Africa', 'https://www.yoco.com', true, 1, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'PayFast', 'Online payments', 'Name, email address, payment amount and reference', 'South Africa', 'https://payfast.io', true, 2, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'WAWP', 'Delivering WhatsApp messages', 'Mobile number and message content', NULL, 'https://wawp.net', true, 3, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'WhatsApp (Meta Platforms)', 'The WhatsApp messaging service', 'Mobile number and message content', NULL, 'https://www.whatsapp.com/legal/privacy-policy', true, 4, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Email delivery provider', 'Sending emails (the SMTP server in Site settings)', 'Name, email address and message content', NULL, NULL, true, 5, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Hosting provider', 'The servers this site and its database run on', 'All information held by the site', 'South Africa', NULL, true, 6, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Google Maps', 'Maps on event pages', 'Your device''s IP address and browser details when a map loads', NULL, 'https://policies.google.com/privacy', true, 7, CURRENT_TIMESTAMP),
|
||||
(gen_random_uuid()::text, 'Sentry', 'Error monitoring (only when it is set up)', 'Technical error reports, which can include the signed-in user''s id', NULL, 'https://sentry.io', false, 8, CURRENT_TIMESTAMP);
|
||||
+493
-16
@@ -7,7 +7,6 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
@@ -76,6 +75,15 @@ model User {
|
||||
notificationPreference NotificationPreference @default(email)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorMethod TwoFactorMethod?
|
||||
// POPIA: opted in to "News & announcements" (marketing) bulk messages, per channel.
|
||||
// Event updates to registrants don't depend on these. History in CommunicationConsent.
|
||||
newsEmail Boolean @default(false)
|
||||
newsWhatsApp Boolean @default(false)
|
||||
// When the person last answered the news opt-in question. Null = ask at next login.
|
||||
newsAskedAt DateTime?
|
||||
// The privacy notice version they last accepted (0 = the built-in notice). Null = never
|
||||
// recorded. Asked again when a version that needs re-acceptance is newer.
|
||||
noticeVersionAccepted Int?
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
|
||||
@@ -89,6 +97,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 +128,14 @@ model User {
|
||||
|
||||
twoFactorChallenges TwoFactorChallenge[]
|
||||
trustedDevices TrustedDevice[]
|
||||
certificates Certificate[]
|
||||
certificatesIssued Certificate[] @relation("CertificateIssuedBy")
|
||||
communicationConsents CommunicationConsent[]
|
||||
dataRequests DataRequest[]
|
||||
dataRequestsHandled DataRequest[] @relation("DataRequestHandledBy")
|
||||
noticesPublished PrivacyNotice[]
|
||||
noticeAcceptances NoticeAcceptance[]
|
||||
dataAccessLogs DataAccessLog[]
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -197,6 +214,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 +252,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 +263,7 @@ model EventOption {
|
||||
costs EventCost[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -255,6 +299,7 @@ model OptionVariant {
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
bundleItems EventBundleItem[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
@@ -265,10 +310,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 +329,7 @@ model Registration {
|
||||
formResponses FormResponse[]
|
||||
formDrafts FormDraft[]
|
||||
couponRedemptions CouponRedemption[]
|
||||
bundleApplications RegistrationBundle[]
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@ -306,11 +355,19 @@ 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)
|
||||
// An all-sessions option on a "pick sessions" event: the sessions chosen (one line,
|
||||
// one ticket, one price). Empty = every session.
|
||||
sessionIds String[] @default([])
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([eventOptionId])
|
||||
@@index([variantId])
|
||||
@@index([appliedTierId])
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
|
||||
@@ -351,6 +408,9 @@ model Payment {
|
||||
externalId String? @unique
|
||||
status String?
|
||||
originalPaymentId String?
|
||||
// Online payments: the payment provider that took it (src/payments, e.g. "yoco"). Null for
|
||||
// payments recorded by staff. Payment.method stays the wallet type (card, apple_pay, …).
|
||||
provider String?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
recordedBy User? @relation("PaymentRecordedBy", fields: [recordedById], references: [id], onDelete: SetNull)
|
||||
@@ -358,7 +418,7 @@ model Payment {
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
|
||||
splitPayments Payment[] @relation("SplitPayments")
|
||||
YocoTransaction YocoTransaction[]
|
||||
providerTransactions ProviderTransaction[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([recordedById])
|
||||
@@ -391,6 +451,7 @@ model Ticket {
|
||||
usages TicketUsage[]
|
||||
transfers TicketTransfer[]
|
||||
transferRequests TicketTransferRequest[]
|
||||
certificates Certificate[]
|
||||
|
||||
@@index([holderUserId])
|
||||
@@index([eventId])
|
||||
@@ -411,6 +472,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 +484,7 @@ model TicketUsage {
|
||||
@@index([ticketId])
|
||||
@@index([scannedAt])
|
||||
@@index([scannedById, scannedAt])
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
model PasswordReset {
|
||||
@@ -495,6 +562,9 @@ enum AdminAuditAction {
|
||||
coupon_updated
|
||||
coupon_disabled
|
||||
coupon_applied_manual
|
||||
bundle_created
|
||||
bundle_updated
|
||||
bundle_deleted
|
||||
consent_override
|
||||
consent_rerequested
|
||||
medical_list_viewed
|
||||
@@ -508,6 +578,21 @@ enum AdminAuditAction {
|
||||
yoco_config_updated
|
||||
yoco_webhook_registered
|
||||
yoco_webhook_deleted
|
||||
payment_providers_updated
|
||||
payfast_config_updated
|
||||
event_sessions_changed
|
||||
attendance_marked
|
||||
attendance_unmarked
|
||||
certificate_issued
|
||||
certificate_revoked
|
||||
job_run_manual
|
||||
data_request_completed
|
||||
data_request_rejected
|
||||
user_anonymised
|
||||
privacy_notice_published
|
||||
operator_created
|
||||
operator_updated
|
||||
operator_deleted
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -622,9 +707,12 @@ model FormDraft {
|
||||
|
||||
// Stores all webhook events from Yoco. If a Payment is created (registration or donation),
|
||||
// the record is marked reconciled and linked via paymentId. Otherwise, remains unreconciled.
|
||||
model YocoTransaction {
|
||||
// A transaction a payment provider told us about (by webhook). Matched to a Payment when it
|
||||
// belongs to one of our checkouts; the rest wait on the Payments page to be reconciled.
|
||||
model ProviderTransaction {
|
||||
id String @id @default(uuid())
|
||||
externalId String @unique // Yoco payment/event id
|
||||
provider String @default("yoco") // payment provider id (src/payments)
|
||||
externalId String // the provider's payment/event id
|
||||
amount Int?
|
||||
currency String?
|
||||
createdDate DateTime?
|
||||
@@ -635,12 +723,37 @@ model YocoTransaction {
|
||||
paymentId String?
|
||||
payment Payment? @relation(fields: [paymentId], references: [id], onDelete: SetNull)
|
||||
raw Json?
|
||||
// Why it wasn't recorded automatically and needs a look (e.g. paid a different amount
|
||||
// from the checkout).
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([provider, externalId])
|
||||
@@index([paymentId])
|
||||
}
|
||||
|
||||
// An online checkout we started: what it's for and how much, so the payment that comes back
|
||||
// can be checked against it. Its id is the payer's link (/pay/<id>) and, for PayFast, the
|
||||
// m_payment_id. `provider` is null until the payer picks one (when several are enabled).
|
||||
model ProviderCheckout {
|
||||
id String @id @default(uuid())
|
||||
provider String?
|
||||
// The provider's own checkout id (Yoco) and where it sends the payer.
|
||||
externalId String?
|
||||
redirectUrl String?
|
||||
amountCents Int
|
||||
currency String @default("ZAR")
|
||||
// Who/what the payment is for: registrationId, userId, eventId, type, donationAmount, …
|
||||
metadata Json
|
||||
// Everything needed to start the checkout with any provider: lineItems, return URLs, payer.
|
||||
request Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([provider, externalId])
|
||||
}
|
||||
|
||||
model Section {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
@@ -709,20 +822,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
|
||||
@@ -854,6 +1015,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 {
|
||||
@@ -1182,6 +1422,8 @@ model WaitlistEntry {
|
||||
eventId String
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
// "Pick sessions" options: the session they're waiting for. Null for everything else.
|
||||
sessionId String?
|
||||
userId String
|
||||
quantity Int
|
||||
position Int
|
||||
@@ -1197,12 +1439,14 @@ model WaitlistEntry {
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([eventId, status, position])
|
||||
@@index([eventOptionId, status])
|
||||
@@index([variantId])
|
||||
@@index([sessionId])
|
||||
@@index([userId])
|
||||
@@index([registrationId])
|
||||
}
|
||||
@@ -1254,3 +1498,236 @@ 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[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@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])
|
||||
}
|
||||
|
||||
// ─── POPIA ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Every change to someone's "News & announcements" opt-in: who, which channel, on or off,
|
||||
// when and how. Append-only — it's the proof of consent. `address` is set instead of
|
||||
// userId for people without an account (an ad-hoc broadcast address that unsubscribed).
|
||||
model CommunicationConsent {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
address String?
|
||||
channel String // email | whatsapp
|
||||
granted Boolean
|
||||
// signup | privacy_centre | prompt | unsubscribe_link | whatsapp_stop | existing_account | admin
|
||||
source String
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([address])
|
||||
}
|
||||
|
||||
// Addresses (email, or phone as 27…) that unsubscribed from news without an account,
|
||||
// e.g. ad-hoc broadcast recipients. News broadcasts skip them.
|
||||
model MessagingOptOut {
|
||||
id String @id @default(uuid())
|
||||
channel String // email | whatsapp
|
||||
address String
|
||||
source String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([channel, address])
|
||||
}
|
||||
|
||||
enum DataRequestType {
|
||||
access
|
||||
correction
|
||||
deletion
|
||||
}
|
||||
|
||||
enum DataRequestStatus {
|
||||
open
|
||||
completed
|
||||
rejected
|
||||
cancelled
|
||||
}
|
||||
|
||||
// A POPIA request from a data subject. Name and email are copied in so the request
|
||||
// still shows who made it after the account is anonymised.
|
||||
model DataRequest {
|
||||
id String @id @default(uuid())
|
||||
type DataRequestType
|
||||
status DataRequestStatus @default(open)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
name String
|
||||
email String
|
||||
details String?
|
||||
// The admin's answer, sent to the requester when the request is closed.
|
||||
response String?
|
||||
// Made and fulfilled by the person themselves (e.g. "Download my data").
|
||||
selfService Boolean @default(false)
|
||||
dueAt DateTime
|
||||
closedAt DateTime?
|
||||
handledById String?
|
||||
handledBy User? @relation("DataRequestHandledBy", fields: [handledById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, dueAt])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// A published version of the privacy notice (versions start at 1). With none published,
|
||||
// the built-in notice from utils/privacyNotice.js is shown as version 0. Section bodies
|
||||
// keep their {placeholders} (org name, Information Officer, operators…), filled in when
|
||||
// shown, so the notice follows Site settings and the operator register.
|
||||
model PrivacyNotice {
|
||||
id String @id @default(uuid())
|
||||
version Int @unique
|
||||
sections Json // [{ id, title, body }]
|
||||
changeSummary String?
|
||||
// Everyone is asked to accept this version again after logging in.
|
||||
requiresAcceptance Boolean @default(false)
|
||||
publishedAt DateTime @default(now())
|
||||
publishedById String?
|
||||
publishedBy User? @relation(fields: [publishedById], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
// Someone accepted a version of the privacy notice. Append-only proof, like
|
||||
// CommunicationConsent.
|
||||
model NoticeAcceptance {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
version Int
|
||||
source String // signup | prompt
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([version])
|
||||
}
|
||||
|
||||
// A third party that processes personal information for the church (POPIA "operator"):
|
||||
// payment providers, WhatsApp and email delivery, hosting… Active ones are listed in the
|
||||
// privacy notice.
|
||||
model Operator {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
purpose String
|
||||
dataShared String
|
||||
location String?
|
||||
website String?
|
||||
active Boolean @default(true)
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Someone exported or viewed personal information in bulk: report exports, the attendance
|
||||
// register, certificate downloads, group lists, form answers, the consent and medical lists,
|
||||
// backups, and viewing the site as another user.
|
||||
model DataAccessLog {
|
||||
id String @id @default(uuid())
|
||||
actorId String?
|
||||
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||
actorRole String
|
||||
// report_pdf | report_excel | report_email | report_whatsapp | attendance_export |
|
||||
// certificates_download | group_lists | form_responses | consent_list | medical_list |
|
||||
// backup_download | impersonation
|
||||
action String
|
||||
what String
|
||||
eventId String?
|
||||
records Int?
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([actorId, createdAt])
|
||||
@@index([action, createdAt])
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -90,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;
|
||||
|
||||
@@ -287,6 +287,10 @@ const getSetLists = async (req, res) => {
|
||||
if (!set) throw allocationError('Allocation set not found', 404);
|
||||
const withMedical = req.query.medical === 'true';
|
||||
const members = await groupMembers(set.groups.map(g => g.id), { withMedical });
|
||||
require('../utils/accessLog').logDataAccess(req, {
|
||||
action: withMedical ? 'medical_list' : 'group_lists', what: `${set.name} lists — ${set.event?.title || 'event'}${withMedical ? ' (with medical details)' : ''}`,
|
||||
eventId: set.eventId, records: members.length,
|
||||
});
|
||||
if (withMedical) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationSet', targetId: set.id, metadata: { eventId: set.eventId, attendees: members.length }, ip: getClientIp(req) });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
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, sessionIds: 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 or ticked sessions: which sessions this person booked, and the ticket for each
|
||||
// (an all-sessions ticket with sessions ticked is one ticket for all of them).
|
||||
bookedSessions: g.sessionIds,
|
||||
sessionTickets: g.sessionIds ? Object.fromEntries(g.tickets.flatMap(tk => (tk.registrationOption.sessionId ? [[tk.registrationOption.sessionId, tk.id]] : g.sessionIds.map(id => [id, 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, sessionIds: 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 && !require('../utils/sessions').lineCovers(ticket.registrationOption, session.id)) throw badRequest('That ticket isn\'t booked for this 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);
|
||||
require('../utils/accessLog').logDataAccess(req, {
|
||||
action: 'attendance_export', what: `Attendance register — ${reg.event.title} (${req.query.format === 'pdf' ? 'PDF' : 'Excel'})`,
|
||||
eventId: req.params.eventId, records: reg.rows.length,
|
||||
});
|
||||
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);
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'certificates_download', what: `Certificates — ${event.title}`, eventId: event.id, records: certs.length });
|
||||
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,
|
||||
};
|
||||
@@ -44,6 +44,7 @@ const downloadBackup = async (req, res) => {
|
||||
res.status(404);
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'backup_download', what: `Database backup ${filename}` });
|
||||
res.download(filePath, filename);
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
|
||||
@@ -30,6 +30,7 @@ function buildEventContext(event) {
|
||||
}
|
||||
|
||||
const { replacePlaceholders } = require('../utils/placeholders');
|
||||
const { normaliseKind, filterRecipients, bulkEmailFooter } = require('../utils/privacy');
|
||||
|
||||
function parseFreeformEmails(lines) {
|
||||
// Supports formats:
|
||||
@@ -103,16 +104,16 @@ const previewBroadcast = async (req, res) => {
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'email', kind });
|
||||
|
||||
// Optionally resolve event info for link/title placeholder
|
||||
let event = null;
|
||||
if (eventId && typeof eventId === 'string') {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
}
|
||||
const eventCtx = buildEventContext(event);
|
||||
|
||||
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
|
||||
return res.json({ kind, matched: recipients.length, skipped, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -147,9 +148,12 @@ const sendBroadcast = async (req, res) => {
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
// Ad-hoc broadcasts are News & announcements unless the sender marks them as an event
|
||||
// update; news skips people who haven't opted in or unsubscribed.
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'email', kind });
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid recipients' });
|
||||
return res.status(400).json({ message: skipped ? 'Everyone selected has unsubscribed from news.' : 'No valid recipients' });
|
||||
}
|
||||
|
||||
// Resolve event
|
||||
@@ -177,9 +181,10 @@ const sendBroadcast = async (req, res) => {
|
||||
eventLinkHtml: eventCtx.eventLinkHtml,
|
||||
} : ctxBase;
|
||||
const finalSubject = await replacePlaceholders(subject, ctxBase);
|
||||
const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined;
|
||||
const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined);
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||
const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: eventCtx.eventTitle });
|
||||
const finalHtml = html ? (await replacePlaceholders(html, ctxForHtml)) + footer.html : undefined;
|
||||
const finalText = (!html ? (await replacePlaceholders(text || '', ctxBase)) + footer.text : undefined);
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText, headers: footer.headers });
|
||||
}));
|
||||
|
||||
const sent = results.filter(r => r.status === 'fulfilled').length;
|
||||
@@ -189,7 +194,7 @@ const sendBroadcast = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ matched: recipients.length, sent });
|
||||
return res.json({ kind, matched: recipients.length, skipped, sent });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -200,13 +205,13 @@ const sendBroadcast = async (req, res) => {
|
||||
// @access Private/Supervisor or Admin
|
||||
const scheduleBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { scheduledAt, subject, html, text, userIds, emails, eventId } = req.body || {};
|
||||
const { scheduledAt, subject, html, text, userIds, emails, eventId, kind } = req.body || {};
|
||||
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
if (!subject || !(html || text)) return res.status(400).json({ message: 'Subject and message (html or text) are required' });
|
||||
|
||||
const payload = { subject, html, text, userIds, emails, eventId };
|
||||
const payload = { subject, html, text, userIds, emails, eventId, kind };
|
||||
|
||||
const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -156,6 +156,7 @@ const listEventConsent = async (req, res) => {
|
||||
const { event, rows } = await consentRows(req.params.eventId);
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const counts = rows.reduce((c, r) => { c[r.status] = (c[r.status] || 0) + 1; return c; }, {});
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'consent_list', what: `Parental consent — ${event.title}`, eventId: event.id, records: rows.length });
|
||||
res.json({
|
||||
event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion },
|
||||
counts,
|
||||
@@ -255,6 +256,7 @@ const medicalList = async (req, res) => {
|
||||
targetType: 'Event', targetId: event.id, metadata: { attendees: list.length },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'medical_list', what: `Medical list — ${event.title}`, eventId: event.id, records: list.length });
|
||||
res.json({ event: { id: event.id, title: event.title, startDate: event.startDate }, rows: list });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
|
||||
@@ -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,44 @@ 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 (an all-sessions ticket with
|
||||
// sessions ticked is still one line at one price).
|
||||
const { isPickOption } = require('../utils/sessions');
|
||||
const perSession = (o) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
|
||||
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? 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 +288,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 +305,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 +319,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 +342,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,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1412,8 +1586,16 @@ const emailEventAttendees = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// POPIA: an "update" (about this event) goes to everyone selected; "news" (the next-event
|
||||
// promo, or when the sender says so) only to people who opted in. Tickets are always sent.
|
||||
const { normaliseKind, filterRecipients, bulkEmailFooter } = require('../utils/privacy');
|
||||
const kind = template === 'tickets' ? 'update' : (promoEvent ? 'news' : (normaliseKind(req.body?.kind) || 'update'));
|
||||
const allowed = await filterRecipients(recipients, { channel: 'email', kind });
|
||||
recipients.length = 0;
|
||||
recipients.push(...allowed.recipients);
|
||||
|
||||
if (dryRun) {
|
||||
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
return res.json({ eventId, kind, matched: recipients.length, skipped: allowed.skipped, recipients: recipients.slice(0, 20) });
|
||||
}
|
||||
|
||||
const { sendMail } = require('../utils/email');
|
||||
@@ -1439,7 +1621,7 @@ const emailEventAttendees = async (req, res) => {
|
||||
|
||||
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
// Generates a live Yoco checkout link for the recipient's first registration with an
|
||||
// Generates a live checkout link for the recipient's first registration with an
|
||||
// outstanding balance — only called when the template actually uses {{payment.link}}, to
|
||||
// avoid an unnecessary Yoco API call per recipient otherwise.
|
||||
function makePaymentLinkResolver(regs) {
|
||||
@@ -1450,8 +1632,8 @@ const emailEventAttendees = async (req, res) => {
|
||||
return due - paid > 0.01;
|
||||
});
|
||||
if (!reg) return '';
|
||||
const { createRegistrationCheckoutInternal } = require('./paymentController');
|
||||
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts');
|
||||
const result = await createRegistrationCheckout(reg.id, reg.userId, {
|
||||
successUrl: `${baseUrl}/payment/success`,
|
||||
cancelUrl: `${baseUrl}/payment/cancel`,
|
||||
failureUrl: `${baseUrl}/payment/failure`,
|
||||
@@ -1556,10 +1738,11 @@ const emailEventAttendees = async (req, res) => {
|
||||
|
||||
// Always perform placeholder replacement on whatever we have
|
||||
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
|
||||
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
|
||||
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
|
||||
const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: event.title });
|
||||
const finalHtml = h ? (await replacePlaceholders(h, ctxHtml)) + footer.html : undefined;
|
||||
const finalText = (!h ? (await replacePlaceholders(t || '', ctxBase)) + footer.text : undefined);
|
||||
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText, headers: footer.headers });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
@@ -1650,10 +1833,15 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
uniqMap.set(phone, { phone, name: r.user?.name || '', userId: r.user?.id, registrationId: r.id });
|
||||
}
|
||||
}
|
||||
const recipients = Array.from(uniqMap.values());
|
||||
// POPIA: see emailEventAttendees — news only to people who opted in to WhatsApp news.
|
||||
const { normaliseKind, filterRecipients, whatsAppNewsFooter } = require('../utils/privacy');
|
||||
const promoEventId = req.body?.promoEventId;
|
||||
const kind = template === 'tickets' ? 'update' : (promoEventId ? 'news' : (normaliseKind(req.body?.kind) || 'update'));
|
||||
const allowed = await filterRecipients(Array.from(uniqMap.values()), { channel: 'whatsapp', kind });
|
||||
const recipients = allowed.recipients;
|
||||
|
||||
if (dryRun) {
|
||||
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
return res.json({ eventId, kind, matched: recipients.length, skipped: allowed.skipped, recipients: recipients.slice(0, 20) });
|
||||
}
|
||||
|
||||
const { sendText } = require('../utils/whatsapp');
|
||||
@@ -1681,7 +1869,7 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
|
||||
|
||||
// Generates a live Yoco checkout link for the recipient's first registration with an
|
||||
// Generates a live checkout link for the recipient's first registration with an
|
||||
// outstanding balance — only called when the template actually uses {{payment.link}}.
|
||||
function makePaymentLinkResolver(regs) {
|
||||
return async () => {
|
||||
@@ -1691,8 +1879,8 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
return due - paid > 0.01;
|
||||
});
|
||||
if (!reg) return '';
|
||||
const { createRegistrationCheckoutInternal } = require('./paymentController');
|
||||
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts');
|
||||
const result = await createRegistrationCheckout(reg.id, reg.userId, {
|
||||
successUrl: `${baseUrl}/payment/success`,
|
||||
cancelUrl: `${baseUrl}/payment/cancel`,
|
||||
failureUrl: `${baseUrl}/payment/failure`,
|
||||
@@ -1753,7 +1941,7 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
}
|
||||
|
||||
const finalMessage = await replacePlaceholders(msg || '', ctx);
|
||||
await sendText(rcpt.phone, finalMessage);
|
||||
await sendText(rcpt.phone, kind === 'news' ? finalMessage + whatsAppNewsFooter(rcpt) : finalMessage);
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
@@ -1781,7 +1969,7 @@ function describeAttendeeFilter(filter) {
|
||||
const scheduleEmailEventAttendees = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
const { scheduledAt, subject, html, text, filter, template } = req.body || {};
|
||||
const { scheduledAt, subject, html, text, filter, template, kind } = req.body || {};
|
||||
|
||||
// Validate event
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
@@ -1799,7 +1987,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
|
||||
}
|
||||
|
||||
// Build payload matching emailEventAttendees body
|
||||
const payload = { subject, html, text, filter: filter || {}, template };
|
||||
const payload = { subject, html, text, filter: filter || {}, template, kind };
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
@@ -1822,7 +2010,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
|
||||
const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
const { scheduledAt, message, filter, template } = req.body || {};
|
||||
const { scheduledAt, message, filter, template, kind } = req.body || {};
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
@@ -1836,7 +2024,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
throw new Error('message is required unless template is "tickets"');
|
||||
}
|
||||
|
||||
const payload = { message, filter: filter || {}, template };
|
||||
const payload = { message, filter: filter || {}, template, kind };
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
|
||||
@@ -43,6 +43,17 @@ const listFormResponses = async (req, res) => {
|
||||
|
||||
const nextCursor = responses.length === take ? responses[responses.length - 1].id : null;
|
||||
|
||||
// The first page of a list (not one registration's own form, nor the next pages of the
|
||||
// same list) is logged as a bulk view of form answers.
|
||||
if (!registrationId && !cursor && responses.length) {
|
||||
require('../utils/accessLog').logDataAccess(req, {
|
||||
action: 'form_responses',
|
||||
what: `Attendee form answers${responses[0].registration?.event && eventId ? ` — ${responses[0].registration.event.title}` : ''}`,
|
||||
eventId: eventId ? String(eventId) : null,
|
||||
records: responses.length,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ items: responses, nextCursor });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
|
||||
@@ -30,6 +30,7 @@ const startImpersonation = async (req, res) => {
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id,
|
||||
metadata: { sessionId: session.id, reason: session.reason, targetName: target.name, expiresAt }, ip: getClientIp(req),
|
||||
});
|
||||
require('../utils/accessLog').logDataAccess(req, { action: 'impersonation', what: `Viewed as ${target.name} — ${session.reason}` });
|
||||
|
||||
// Optional: tell the user (Site Settings → Security).
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,97 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { encrypt } = require('../utils/encryption');
|
||||
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { maskSecret } = require('../utils/yoco');
|
||||
const {
|
||||
KEY_MERCHANT_ID, KEY_MERCHANT_KEY, KEY_PASSPHRASE, KEY_MODE,
|
||||
getPayfastConfig, payfastNotifyUrl, notifyUrlProblem,
|
||||
} = require('../utils/payfast');
|
||||
|
||||
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
|
||||
|
||||
// @desc PayFast settings (passphrase masked) and the ITN notify URL
|
||||
// @route GET /api/payfast/config
|
||||
// @access Admin
|
||||
const getConfig = async (req, res) => {
|
||||
try {
|
||||
const c = await getPayfastConfig();
|
||||
const notifyUrl = payfastNotifyUrl();
|
||||
res.json({
|
||||
merchantId: c.merchantId,
|
||||
merchantKey: c.merchantKey,
|
||||
passphraseMasked: c.passphrase ? maskSecret(c.passphrase) : '',
|
||||
hasPassphrase: !!c.passphrase,
|
||||
mode: c.mode,
|
||||
notifyUrl,
|
||||
notifyUrlProblem: notifyUrlProblem(notifyUrl),
|
||||
configured: !!(c.merchantId && c.merchantKey),
|
||||
});
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save PayFast settings ('' clears the passphrase; omitted fields stay as they are)
|
||||
// @route PUT /api/payfast/config { merchantId?, merchantKey?, passphrase?, mode? }
|
||||
// @access Admin
|
||||
const updateConfig = async (req, res) => {
|
||||
try {
|
||||
const { merchantId, merchantKey, passphrase, mode } = req.body || {};
|
||||
const updates = {};
|
||||
if (typeof merchantId === 'string') {
|
||||
const v = merchantId.trim();
|
||||
if (v && !/^\d{5,12}$/.test(v)) { res.status(400); throw new Error('The merchant ID is a number, e.g. 10000100.'); }
|
||||
updates[KEY_MERCHANT_ID] = v;
|
||||
}
|
||||
if (typeof merchantKey === 'string') {
|
||||
const v = merchantKey.trim();
|
||||
if (v && !/^[A-Za-z0-9]{8,32}$/.test(v)) { res.status(400); throw new Error('The merchant key is letters and numbers, as shown on your PayFast dashboard.'); }
|
||||
updates[KEY_MERCHANT_KEY] = v;
|
||||
}
|
||||
if (typeof passphrase === 'string') {
|
||||
// PayFast passphrases are 32 characters at most; spaces at the ends are trimmed by PayFast.
|
||||
if (passphrase.trim().length > 32) { res.status(400); throw new Error('The passphrase can be at most 32 characters.'); }
|
||||
updates[KEY_PASSPHRASE] = passphrase.trim();
|
||||
}
|
||||
if (mode !== undefined) {
|
||||
if (!['sandbox', 'live'].includes(mode)) { res.status(400); throw new Error('Mode is sandbox or live.'); }
|
||||
updates[KEY_MODE] = mode;
|
||||
}
|
||||
if (!Object.keys(updates).length) { res.status(400); throw new Error('Nothing to save'); }
|
||||
|
||||
await prisma.$transaction(Object.entries(updates).map(([key, value]) => {
|
||||
const stored = value && key === KEY_PASSPHRASE ? encrypt(value) : value;
|
||||
return prisma.appSetting.upsert({ where: { key }, update: { value: stored }, create: { key, value: stored } });
|
||||
}));
|
||||
invalidateSettingsCache();
|
||||
await warmCache();
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'payfast_config_updated', targetType: 'AppSetting',
|
||||
metadata: { changedKeys: Object.keys(updates), ...(updates[KEY_MODE] ? { mode: updates[KEY_MODE] } : {}) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ message: 'PayFast settings saved' });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Check the saved merchant ID and passphrase with PayFast (a signed API ping)
|
||||
// @route POST /api/payfast/test
|
||||
// @access Admin
|
||||
const testConnection = async (req, res) => {
|
||||
try {
|
||||
const c = await getPayfastConfig();
|
||||
if (!c.merchantId) { res.status(400); throw new Error('Save the merchant ID first.'); }
|
||||
await require('../payments/providers/payfast').testConnection();
|
||||
res.json({ message: `Connected to PayFast (${c.mode === 'live' ? 'live' : 'sandbox'}).` });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getConfig, updateConfig, testConnection };
|
||||
@@ -5,8 +5,8 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
|
||||
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 payments = require('../payments');
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
@@ -914,113 +914,23 @@ const unassignDonationFromRegistration = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Yoco line items for a registration: one per item at each price actually charged
|
||||
* (tranche-aware, so a line bought partly at early-bird shows both prices), plus the
|
||||
* coupon discount as a negative line. Previous/partial payment lines are added by callers.
|
||||
*/
|
||||
function checkoutLineItems(registration) {
|
||||
const { buildLineItems, couponDiscountLine } = require('../utils/pdfDocs');
|
||||
const items = buildLineItems(registration.registrationOptions).map(row => ({
|
||||
displayName: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
|
||||
quantity: row.quantity,
|
||||
pricingDetails: { price: Math.round(row.unitPrice * 100) },
|
||||
}));
|
||||
const coupon = couponDiscountLine(registration);
|
||||
if (coupon) {
|
||||
items.push({ displayName: coupon.label, quantity: 1, pricingDetails: { price: -Math.round(coupon.amount * 100) } });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
|
||||
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
|
||||
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: true,
|
||||
tranches: true,
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!registration) throw new Error('Registration not found');
|
||||
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const remainingAmount = totalDue - totalPaid;
|
||||
|
||||
if (remainingAmount <= 0) throw new Error('This registration is already paid in full');
|
||||
|
||||
const amountInCents = Math.round(remainingAmount * 100);
|
||||
const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`;
|
||||
const lineItems = checkoutLineItems(registration);
|
||||
|
||||
// Show prior payments as a deduction line
|
||||
if (totalPaid > 0) {
|
||||
lineItems.push({
|
||||
displayName: 'Previous Payments',
|
||||
quantity: 1,
|
||||
pricingDetails: { price: -Math.round(totalPaid * 100) }
|
||||
});
|
||||
}
|
||||
// Paying full remaining — no "Partial Payment" line needed
|
||||
|
||||
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': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
|
||||
);
|
||||
|
||||
await prisma.registration.update({
|
||||
where: { id: registrationId },
|
||||
data: { checkoutId: response.data.id, updatedAt: new Date() }
|
||||
});
|
||||
|
||||
return {
|
||||
checkoutId: response.data.id,
|
||||
redirectUrl: response.data.redirectUrl,
|
||||
amount: remainingAmount,
|
||||
registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title }
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Create a Yoco checkout for a registration
|
||||
// @route POST /api/payments/yoco-checkout
|
||||
// @desc Start an online checkout: a registration's balance (all of it, or part of it, plus
|
||||
// an optional donation) or a donation to an event. `provider` picks the payment
|
||||
// provider; by default the first one enabled in Site settings → Payments.
|
||||
// @route POST /api/payments/checkout (also /api/payments/yoco-checkout)
|
||||
// @access Private
|
||||
const createYocoCheckout = async (req, res) => {
|
||||
const createCheckout = async (req, res) => {
|
||||
try {
|
||||
const { registrationId, eventId, amount, successUrl, cancelUrl, failureUrl } = req.body;
|
||||
const { registrationId, eventId, amount, donationAmount, successUrl, cancelUrl, failureUrl, provider } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Validate required parameters for either registration payment or donation
|
||||
if (!registrationId && !eventId) {
|
||||
res.status(400);
|
||||
throw new Error('Either registrationId or eventId is required');
|
||||
}
|
||||
|
||||
// Branch 1: Registration payment — delegate to internal helper
|
||||
// A registration's balance
|
||||
if (registrationId) {
|
||||
// Authorisation check (the internal helper skips this)
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
|
||||
if (!reg) { res.status(404); throw new Error('Registration not found'); }
|
||||
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
|
||||
@@ -1033,10 +943,9 @@ const createYocoCheckout = async (req, res) => {
|
||||
await assertEventOpen(reg.eventId, res);
|
||||
|
||||
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
|
||||
// Must run before totalDue is computed so the correct price is used for the checkout amount.
|
||||
// Must run before the balance is worked out so the checkout charges the right price.
|
||||
const { changed: priceChanged } = await refreshPricingForRegistration(registrationId);
|
||||
if (priceChanged) {
|
||||
// Re-fetch with fresh priceSnapshot values
|
||||
const freshReg = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
@@ -1053,125 +962,75 @@ const createYocoCheckout = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// If a specific partial amount was passed, fall through to the old inline path
|
||||
// Part of the balance (supervisor payments page)
|
||||
if (amount && !isNaN(parseFloat(amount))) {
|
||||
// Re-use old inline logic for partial payments
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: true,
|
||||
tranches: true,
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: true
|
||||
}
|
||||
});
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const remainingAmount = totalDue - totalPaid;
|
||||
if (remainingAmount <= 0) { res.status(400); throw new Error('This registration is already paid in full'); }
|
||||
const requested = parseFloat(amount);
|
||||
if (requested <= 0) { res.status(400); throw new Error('Amount must be greater than 0'); }
|
||||
if (remainingAmount >= 15 && requested < 15) { res.status(400); throw new Error('Minimum payment is R15'); }
|
||||
const charge = Math.min(requested, remainingAmount);
|
||||
const amountInCents = Math.round(charge * 100);
|
||||
const lineItems = checkoutLineItems(registration);
|
||||
// Previous payments deduction
|
||||
if (totalPaid > 0) {
|
||||
lineItems.push({ displayName: 'Previous Payments', quantity: 1, pricingDetails: { price: -Math.round(totalPaid * 100) } });
|
||||
}
|
||||
// Partial payment deduction — amount being deferred to a future payment
|
||||
if (charge < remainingAmount) {
|
||||
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': 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 createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, amount: parseFloat(amount), provider });
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
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 < payments.MIN_ONLINE_AMOUNT) { res.status(400); throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`); }
|
||||
}
|
||||
|
||||
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation, provider });
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
// Branch 2: Donation to an event
|
||||
if (eventId) {
|
||||
// Validate event
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
if (!event.isActive) {
|
||||
res.status(400);
|
||||
throw new Error('Cannot make donation to inactive event');
|
||||
}
|
||||
if (event.cashupStatus === 'closed') {
|
||||
res.status(400);
|
||||
throw new Error('This event is closed and no longer accepting donations.');
|
||||
}
|
||||
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt > 0)) {
|
||||
res.status(400);
|
||||
throw new Error('Valid amount is required for donation');
|
||||
}
|
||||
if (amt < 15) {
|
||||
res.status(400);
|
||||
throw new Error('Minimum donation is R15');
|
||||
}
|
||||
|
||||
const amountInCents = Math.round(amt * 100);
|
||||
const idempotencyKey = `don_${eventId}_${amountInCents}_${Date.now()}`;
|
||||
const metadata = {
|
||||
userId,
|
||||
eventId,
|
||||
eventTitle: event.title,
|
||||
type: 'donation',
|
||||
isDonation: true
|
||||
};
|
||||
|
||||
const checkoutData = {
|
||||
amount: amountInCents,
|
||||
currency: 'ZAR',
|
||||
metadata,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
failureUrl,
|
||||
lineItems: [
|
||||
{ displayName: `Donation to ${event.title}`, quantity: 1, pricingDetails: { price: amountInCents } }
|
||||
]
|
||||
};
|
||||
|
||||
const response = await axios.post(
|
||||
'https://payments.yoco.com/api/checkouts',
|
||||
checkoutData,
|
||||
{ headers: { 'Content-Type': 'application/json', 'Authorization': await yocoAuthHeader(), 'Idempotency-Key': idempotencyKey } }
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
checkoutId: response.data.id,
|
||||
redirectUrl: response.data.redirectUrl,
|
||||
amount: amt,
|
||||
event: { id: event.id, title: event.title }
|
||||
});
|
||||
// A donation to an event
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Yoco checkout error:', error.response?.data || error.message);
|
||||
res.status(error.statusCode || error.response?.status || 400).json({
|
||||
message: error.response?.data?.message || error.message
|
||||
if (!event.isActive) {
|
||||
res.status(400);
|
||||
throw new Error('Cannot make donation to inactive event');
|
||||
}
|
||||
if (event.cashupStatus === 'closed') {
|
||||
res.status(400);
|
||||
throw new Error('This event is closed and no longer accepting donations.');
|
||||
}
|
||||
|
||||
const amt = parseFloat(amount);
|
||||
if (!(amt > 0)) {
|
||||
res.status(400);
|
||||
throw new Error('Valid amount is required for donation');
|
||||
}
|
||||
if (amt < payments.MIN_ONLINE_AMOUNT) {
|
||||
res.status(400);
|
||||
throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`);
|
||||
}
|
||||
|
||||
const amountInCents = Math.round(amt * 100);
|
||||
const checkout = await payments.createCheckout(provider, {
|
||||
amountCents: amountInCents,
|
||||
metadata: { userId, eventId, eventTitle: event.title, type: 'donation', isDonation: true },
|
||||
lineItems: [{ name: `Donation to ${event.title}`, quantity: 1, unitCents: amountInCents }],
|
||||
description: `Donation to ${event.title}`,
|
||||
payer: { name: req.user.name || '', email: req.user.email || '' },
|
||||
successUrl, cancelUrl, failureUrl,
|
||||
idempotencyKey: `don_${eventId}_${amountInCents}_${Date.now()}`,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
checkoutId: checkout.checkoutId,
|
||||
redirectUrl: checkout.redirectUrl,
|
||||
provider: checkout.provider,
|
||||
amount: amt,
|
||||
event: { id: event.id, title: event.title }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Checkout error:', error.message);
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Deliver a previously-generated Yoco payment link to the registrant via email or WhatsApp
|
||||
// @route POST /api/payments/yoco-checkout/send
|
||||
// @desc Deliver a previously-generated payment link to the registrant via email or WhatsApp
|
||||
// @route POST /api/payments/checkout/send (also /api/payments/yoco-checkout/send)
|
||||
// @access Private/Supervisor
|
||||
const sendPaymentLink = async (req, res) => {
|
||||
try {
|
||||
@@ -1460,8 +1319,7 @@ module.exports = {
|
||||
getPaymentsByEvent,
|
||||
assignDonationToRegistration,
|
||||
unassignDonationFromRegistration,
|
||||
createYocoCheckout,
|
||||
createRegistrationCheckoutInternal,
|
||||
createCheckout,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
const prisma = require('../config/db');
|
||||
const payments = require('../payments');
|
||||
const { checkoutPaid, checkCheckouts, openCheckouts } = require('../payments/status');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
|
||||
const fail = (res, e) => res.status(e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(e) });
|
||||
|
||||
// @desc Payment providers people can pay with now (enabled and set up), in display order
|
||||
// @route GET /api/payments/providers
|
||||
// @access Public
|
||||
const listPaymentProviders = async (req, res) => {
|
||||
try {
|
||||
const providers = await payments.availableProviders();
|
||||
res.json({ providers: providers.map(p => ({ id: p.id, label: p.label })), minimumAmount: payments.MIN_ONLINE_AMOUNT });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Every provider with its Enabled flag, order and whether it's set up
|
||||
// @route GET /api/payments/providers/settings
|
||||
// @access Admin
|
||||
const getPaymentProviderSettings = async (req, res) => {
|
||||
try {
|
||||
res.json({ providers: await payments.providerSettings(), minimumAmount: payments.MIN_ONLINE_AMOUNT });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Turn providers on/off and set their order
|
||||
// @route PUT /api/payments/providers/settings { providers: [{ id, enabled }] } (in display order)
|
||||
// @access Admin
|
||||
const updatePaymentProviderSettings = async (req, res) => {
|
||||
try {
|
||||
const list = req.body?.providers;
|
||||
if (!Array.isArray(list) || list.length === 0) { res.status(400); throw new Error('providers must be a list'); }
|
||||
const unknown = list.find(p => !payments.getProvider(p?.id));
|
||||
if (unknown) { res.status(400); throw new Error(`Unknown payment provider: ${unknown?.id}`); }
|
||||
|
||||
const before = await payments.providerSettings();
|
||||
const value = payments.serializeProviderSettings(list);
|
||||
await prisma.appSetting.upsert({ where: { key: payments.SETTINGS_KEY }, update: { value }, create: { key: payments.SETTINGS_KEY, value } });
|
||||
invalidateSettingsCache();
|
||||
await warmCache();
|
||||
const after = await payments.providerSettings();
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'payment_providers_updated', targetType: 'AppSetting',
|
||||
metadata: { before: before.map(p => ({ id: p.id, enabled: p.enabled })), after: after.map(p => ({ id: p.id, enabled: p.enabled })) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ message: 'Payment providers saved', providers: after });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
/** A checkout and whether it has been paid (a payment came back for it, or its registration is paid). */
|
||||
async function loadCheckout(id) {
|
||||
const checkout = await prisma.providerCheckout.findUnique({ where: { id: String(id || '') } });
|
||||
if (!checkout) return null;
|
||||
return { checkout, paid: await checkoutPaid(checkout) };
|
||||
}
|
||||
|
||||
const providerList = (list) => list.map(p => ({ id: p.id, label: p.label }));
|
||||
|
||||
// @desc A checkout for the payer's page (/pay/:id): amount, what it's for, the providers
|
||||
// they can pay with, and — once a form provider (PayFast) is chosen — its signed form
|
||||
// @route GET /api/payments/pay/:id
|
||||
// @access Public (the checkout id is the credential)
|
||||
const getPayPage = async (req, res) => {
|
||||
try {
|
||||
const found = await loadCheckout(req.params.id);
|
||||
if (!found) { res.status(404); throw new Error('This payment link is not valid.'); }
|
||||
const { checkout, paid } = found;
|
||||
const request = checkout.request || {};
|
||||
const out = {
|
||||
id: checkout.id,
|
||||
status: paid ? 'paid' : 'open',
|
||||
amount: checkout.amountCents / 100,
|
||||
description: request.description || '',
|
||||
provider: checkout.provider,
|
||||
providers: providerList(await payments.availableProviders()),
|
||||
cancelUrl: request.cancelUrl || null,
|
||||
redirectUrl: null,
|
||||
form: null,
|
||||
};
|
||||
if (!paid && checkout.provider) {
|
||||
const provider = payments.getProvider(checkout.provider);
|
||||
if (checkout.redirectUrl) out.redirectUrl = checkout.redirectUrl;
|
||||
else if (provider) out.form = (await payments.startCheckout(checkout, provider)).form || null;
|
||||
}
|
||||
res.json(out);
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Start a checkout with the provider the payer picked
|
||||
// @route POST /api/payments/pay/:id { provider }
|
||||
// @access Public (the checkout id is the credential)
|
||||
const choosePayProvider = async (req, res) => {
|
||||
try {
|
||||
const found = await loadCheckout(req.params.id);
|
||||
if (!found) { res.status(404); throw new Error('This payment link is not valid.'); }
|
||||
if (found.paid) { res.status(409); throw new Error('This has already been paid.'); }
|
||||
const provider = payments.getProvider(req.body?.provider);
|
||||
if (!provider) { res.status(400); throw new Error('Choose how you\'d like to pay.'); }
|
||||
const usable = (await payments.availableProviders()).some(p => p.id === provider.id);
|
||||
if (!usable) { res.status(400); throw new Error(`Paying with ${provider.label} isn't available right now.`); }
|
||||
const started = await payments.startCheckout(found.checkout, provider);
|
||||
res.json({ provider: provider.id, redirectUrl: started.form ? null : started.redirectUrl, form: started.form || null });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
const OPEN_DAYS = 14;
|
||||
|
||||
/** An open checkout for the Payments page: who/what it's for, and whether its provider can be asked. */
|
||||
async function describeCheckout(c, users) {
|
||||
const meta = c.metadata || {};
|
||||
const provider = payments.getProvider(c.provider);
|
||||
return {
|
||||
id: c.id,
|
||||
provider: c.provider,
|
||||
providerLabel: provider?.label || c.provider,
|
||||
canCheck: !!provider?.findPayments,
|
||||
amount: c.amountCents / 100,
|
||||
description: c.request?.description || meta.eventTitle || '',
|
||||
type: meta.type || null,
|
||||
registrationId: meta.registrationId || null,
|
||||
payer: users.get(meta.userId) || c.request?.payer?.name || null,
|
||||
createdAt: c.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Online checkouts started in the last 14 days that have no payment yet
|
||||
// @route GET /api/payments/checkouts/open
|
||||
// @access Supervisor
|
||||
const listOpenCheckouts = async (req, res) => {
|
||||
try {
|
||||
const open = await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) });
|
||||
const userIds = [...new Set(open.map(c => c.metadata?.userId).filter(Boolean))];
|
||||
const users = new Map((await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })).map(u => [u.id, u.name]));
|
||||
res.json({ checkouts: await Promise.all(open.map(c => describeCheckout(c, users))), days: OPEN_DAYS });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Ask the providers whether open checkouts were paid, and record what they find
|
||||
// @route POST /api/payments/checkouts/check { ids?: string[] } (default: every open one)
|
||||
// @access Supervisor
|
||||
const checkCheckoutStatus = async (req, res) => {
|
||||
try {
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String).slice(0, 200) : null;
|
||||
const list = ids
|
||||
? await prisma.providerCheckout.findMany({ where: { id: { in: ids } } })
|
||||
: await openCheckouts({ since: new Date(Date.now() - OPEN_DAYS * 24 * 60 * 60 * 1000) });
|
||||
const results = await checkCheckouts(list);
|
||||
const out = Object.fromEntries(results);
|
||||
const recorded = [...results.values()].filter(r => r.status === 'recorded').length;
|
||||
res.json({ results: out, recorded, message: recorded ? `Found and recorded ${recorded} payment${recorded === 1 ? '' : 's'}.` : 'No new payments found.' });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider,
|
||||
listOpenCheckouts, checkCheckoutStatus,
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { ACCESS_ACTIONS } = require('../utils/accessLog');
|
||||
const notice = require('../utils/privacyNotice');
|
||||
|
||||
const fail = (res, error) => res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const page = req => Math.max(parseInt(req.query.page, 10) || 1, 1);
|
||||
|
||||
// ─── Privacy notice ───────────────────────────────────────────────────────────
|
||||
|
||||
// @desc The privacy notice as published (current, or ?version=N), placeholders filled in
|
||||
// @route GET /api/privacy/notice
|
||||
// @access Public
|
||||
const getNotice = async (req, res) => {
|
||||
try {
|
||||
const version = req.query.version != null ? parseInt(req.query.version, 10) : null;
|
||||
if (req.query.version != null && !(version >= 0)) { res.status(400); throw new Error('Invalid version'); }
|
||||
const result = await notice.publicNotice(version);
|
||||
if (!result) { res.status(404); throw new Error('That version of the privacy notice does not exist.'); }
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Accept the current privacy notice
|
||||
// @route POST /api/privacy/me/notice/accept
|
||||
// @access Private
|
||||
const acceptMyNotice = async (req, res) => {
|
||||
try {
|
||||
if (req.impersonation) { res.status(403); throw new Error('Not available while viewing as another user.'); }
|
||||
const version = await notice.acceptNotice(req.user.id, { source: 'prompt', ip: getClientIp(req) });
|
||||
res.json({ version });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
async function versionList() {
|
||||
const [versions, counts] = await Promise.all([
|
||||
prisma.privacyNotice.findMany({
|
||||
orderBy: { version: 'desc' },
|
||||
select: { version: true, publishedAt: true, changeSummary: true, requiresAcceptance: true, publishedBy: { select: { name: true } } },
|
||||
}),
|
||||
prisma.noticeAcceptance.groupBy({ by: ['version'], _count: { _all: true } }),
|
||||
]);
|
||||
const byVersion = new Map(counts.map(c => [c.version, c._count._all]));
|
||||
return versions.map(v => ({ ...v, publishedBy: v.publishedBy?.name || null, acceptances: byVersion.get(v.version) || 0 }));
|
||||
}
|
||||
|
||||
// @desc The editor: the draft (or current text), how it reads, and the published versions
|
||||
// @route GET /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
const getNoticeDraft = async (req, res) => {
|
||||
try {
|
||||
const draft = await notice.getDraft();
|
||||
const ctx = await notice.noticeContext();
|
||||
res.json({
|
||||
...draft,
|
||||
rendered: notice.renderSections(draft.sections, ctx),
|
||||
placeholders: notice.PLACEHOLDERS,
|
||||
versions: await versionList(),
|
||||
builtIn: notice.defaultSections(),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save the draft (body.sections)
|
||||
// @route PUT /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
const saveNoticeDraft = async (req, res) => {
|
||||
try {
|
||||
const sections = await notice.saveDraft(req.body?.sections);
|
||||
res.json({ sections, rendered: notice.renderSections(sections, await notice.noticeContext()), isDraft: true, savedAt: new Date() });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Throw the draft away (back to the current published text)
|
||||
// @route DELETE /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
const discardNoticeDraft = async (req, res) => {
|
||||
try {
|
||||
await notice.discardDraft();
|
||||
res.json({ message: 'Draft discarded.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Publish body.sections as the next version (body.changeSummary, body.requiresAcceptance)
|
||||
// @route POST /api/privacy/notice/publish
|
||||
// @access Private/Admin
|
||||
const publishNotice = async (req, res) => {
|
||||
try {
|
||||
const { sections, changeSummary, requiresAcceptance } = req.body || {};
|
||||
if (requiresAcceptance && !String(changeSummary || '').trim()) {
|
||||
res.status(400);
|
||||
throw new Error('Say what changed — people see it when they are asked to accept the new version.');
|
||||
}
|
||||
const published = await notice.publish({ sections, changeSummary, requiresAcceptance, publishedById: req.user.id });
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'privacy_notice_published', targetType: 'privacy_notice', targetId: published.id,
|
||||
metadata: { version: published.version, requiresAcceptance: published.requiresAcceptance, changeSummary: published.changeSummary }, ip: getClientIp(req),
|
||||
});
|
||||
res.status(201).json({ version: published.version, publishedAt: published.publishedAt, requiresAcceptance: published.requiresAcceptance });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Operators ────────────────────────────────────────────────────────────────
|
||||
|
||||
const OPERATOR_FIELDS = ['name', 'purpose', 'dataShared', 'location', 'website'];
|
||||
|
||||
function operatorData(body, { partial = false } = {}) {
|
||||
const data = {};
|
||||
for (const f of OPERATOR_FIELDS) {
|
||||
if (body?.[f] === undefined) continue;
|
||||
const v = body[f] == null ? '' : String(body[f]).trim().slice(0, f === 'name' ? 200 : 1000);
|
||||
data[f] = v || (f === 'location' || f === 'website' ? null : '');
|
||||
}
|
||||
if (typeof body?.active === 'boolean') data.active = body.active;
|
||||
if (Number.isInteger(body?.order)) data.order = body.order;
|
||||
if ((!partial || 'name' in data) && !data.name) throw new Error('Give the operator a name.');
|
||||
if ((!partial || 'purpose' in data) && !data.purpose) throw new Error('Say what the operator does for you.');
|
||||
if (data.website && !/^https?:\/\//i.test(data.website)) data.website = `https://${data.website}`;
|
||||
return data;
|
||||
}
|
||||
|
||||
// @desc The operator register
|
||||
// @route GET /api/privacy/operators
|
||||
// @access Private/Admin
|
||||
const listOperators = async (req, res) => {
|
||||
try {
|
||||
res.json(await prisma.operator.findMany({ orderBy: [{ order: 'asc' }, { name: 'asc' }] }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/privacy/operators
|
||||
const createOperator = async (req, res) => {
|
||||
try {
|
||||
const data = operatorData(req.body);
|
||||
if (data.order === undefined) data.order = (await prisma.operator.count()) + 1;
|
||||
const op = await prisma.operator.create({ data });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_created', targetType: 'operator', targetId: op.id, metadata: { name: op.name }, ip: getClientIp(req) });
|
||||
res.status(201).json(op);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/privacy/operators/:id
|
||||
const updateOperator = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Operator not found'); }
|
||||
const op = await prisma.operator.update({ where: { id: existing.id }, data: operatorData(req.body, { partial: true }) });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_updated', targetType: 'operator', targetId: op.id, metadata: { name: op.name, active: op.active }, ip: getClientIp(req) });
|
||||
res.json(op);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/privacy/operators/:id
|
||||
const deleteOperator = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Operator not found'); }
|
||||
await prisma.operator.delete({ where: { id: existing.id } });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_deleted', targetType: 'operator', targetId: existing.id, metadata: { name: existing.name }, ip: getClientIp(req) });
|
||||
res.json({ message: 'Operator removed.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Consent log ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Filter on the person's name or email.
|
||||
function personWhere(q) {
|
||||
return q ? { OR: [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] } : undefined;
|
||||
}
|
||||
|
||||
// @desc Proof of consent: ?type=news (news opt-in changes), notice (privacy notice
|
||||
// acceptances) or parental (parental consent answers); ?q= name/email; ?page=
|
||||
// @route GET /api/privacy/consent-log
|
||||
// @access Private/Admin
|
||||
const consentLog = async (req, res) => {
|
||||
try {
|
||||
const type = String(req.query.type || 'news');
|
||||
const q = String(req.query.q || '').trim();
|
||||
const skip = (page(req) - 1) * PAGE_SIZE;
|
||||
const user = { select: { name: true, email: true } };
|
||||
|
||||
if (type === 'news') {
|
||||
const where = q ? { OR: [{ user: personWhere(q) }, { address: { contains: q, mode: 'insensitive' } }] } : {};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.communicationConsent.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
|
||||
prisma.communicationConsent.count({ where }),
|
||||
]);
|
||||
return res.json({
|
||||
total, pageSize: PAGE_SIZE,
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || r.address, channel: r.channel, granted: r.granted, source: r.source, ip: r.ip })),
|
||||
});
|
||||
}
|
||||
if (type === 'notice') {
|
||||
const where = q ? { user: personWhere(q) } : {};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.noticeAcceptance.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
|
||||
prisma.noticeAcceptance.count({ where }),
|
||||
]);
|
||||
return res.json({
|
||||
total, pageSize: PAGE_SIZE,
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || null, version: r.version, source: r.source, ip: r.ip })),
|
||||
});
|
||||
}
|
||||
if (type === 'parental') {
|
||||
const where = {
|
||||
status: { in: ['given', 'declined'] },
|
||||
...(q ? { OR: [{ attendee: { name: { contains: q, mode: 'insensitive' } } }, { parentName: { contains: q, mode: 'insensitive' } }, { parentEmail: { contains: q, mode: 'insensitive' } }, { signedName: { contains: q, mode: 'insensitive' } }] } : {}),
|
||||
};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.consentRequest.findMany({
|
||||
where, orderBy: { respondedAt: 'desc' }, skip, take: PAGE_SIZE,
|
||||
select: { id: true, status: true, signedName: true, parentName: true, textVersion: true, photoConsent: true, respondedAt: true, ip: true, medicalPurgedAt: true, attendee: { select: { name: true } }, event: { select: { title: true } } },
|
||||
}),
|
||||
prisma.consentRequest.count({ where }),
|
||||
]);
|
||||
return res.json({
|
||||
total, pageSize: PAGE_SIZE,
|
||||
rows: rows.map(r => ({
|
||||
id: r.id, at: r.respondedAt, child: r.attendee?.name, event: r.event?.title, status: r.status, signedName: r.signedName,
|
||||
parentName: r.parentName, textVersion: r.textVersion, photoConsent: r.photoConsent, ip: r.ip, medicalDeleted: !!r.medicalPurgedAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
res.status(400);
|
||||
throw new Error('type must be news, notice or parental');
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Access log ───────────────────────────────────────────────────────────────
|
||||
|
||||
// @desc Who exported or viewed personal information in bulk (?action=, ?actorId=, ?from=, ?to=, ?page=)
|
||||
// @route GET /api/privacy/access-log
|
||||
// @access Private/Admin
|
||||
const accessLog = async (req, res) => {
|
||||
try {
|
||||
const where = {};
|
||||
if (req.query.action) where.action = String(req.query.action);
|
||||
if (req.query.actorId) where.actorId = String(req.query.actorId);
|
||||
if (req.query.from || req.query.to) {
|
||||
where.createdAt = {};
|
||||
if (req.query.from) where.createdAt.gte = new Date(String(req.query.from));
|
||||
if (req.query.to) where.createdAt.lte = new Date(String(req.query.to));
|
||||
}
|
||||
const [rows, total, actors] = await Promise.all([
|
||||
prisma.dataAccessLog.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page(req) - 1) * PAGE_SIZE, take: PAGE_SIZE, include: { actor: { select: { name: true } } } }),
|
||||
prisma.dataAccessLog.count({ where }),
|
||||
prisma.dataAccessLog.findMany({ where: { actorId: { not: null } }, distinct: ['actorId'], select: { actorId: true, actor: { select: { name: true } } } }),
|
||||
]);
|
||||
res.json({
|
||||
total, pageSize: PAGE_SIZE,
|
||||
actions: ACCESS_ACTIONS,
|
||||
actors: actors.map(a => ({ id: a.actorId, name: a.actor?.name || 'Unknown' })),
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, actor: r.actor?.name || 'Deleted user', actorRole: r.actorRole, action: r.action, what: r.what, records: r.records, eventId: r.eventId, ip: r.ip })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getNotice,
|
||||
acceptMyNotice,
|
||||
getNoticeDraft,
|
||||
saveNoticeDraft,
|
||||
discardNoticeDraft,
|
||||
publishNotice,
|
||||
listOperators,
|
||||
createOperator,
|
||||
updateOperator,
|
||||
deleteOperator,
|
||||
consentLog,
|
||||
accessLog,
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
const prisma = require('../config/db');
|
||||
const { comparePassword } = require('../config/auth');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
const privacy = require('../utils/privacy');
|
||||
|
||||
const fail = (res, error) => res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const REQUEST_LABEL = { access: 'access to your information', correction: 'correction of your information', deletion: 'deletion of your account and personal information' };
|
||||
|
||||
function frontendUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function sendPrivacyEmail(to, subject, content) {
|
||||
const { sendMail, buildPrivacyEmail } = require('../utils/email');
|
||||
if (!to) return;
|
||||
sendMail({ to, subject, ...buildPrivacyEmail(content) }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Tell the Information Officer (when set) and the admins that a request came in. */
|
||||
async function notifyAdminsOfRequest(request) {
|
||||
const to = await privacy.privacyContacts();
|
||||
const url = `${frontendUrl()}/dashboard/admin/data-requests`;
|
||||
for (const email of to) {
|
||||
sendPrivacyEmail(email, `New privacy request: ${request.type}`, {
|
||||
name: '',
|
||||
heading: 'New privacy request',
|
||||
paragraphs: [
|
||||
`${request.name} (${request.email}) asked for ${REQUEST_LABEL[request.type]}.`,
|
||||
...(request.details ? [`Their message: ${request.details}`] : []),
|
||||
`POPIA asks for an answer within 30 days — by ${request.dueAt.toLocaleDateString('en-ZA')}. Open the Data requests page to handle it: ${url}`,
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Delete my account" (password already checked by the caller): lock the account straight
|
||||
* away and file a deletion request. The personal information is removed when an admin
|
||||
* approves — registrations for upcoming events have to be sorted out first.
|
||||
*/
|
||||
async function requestAccountDeletion(user, { details = null } = {}) {
|
||||
const request = await privacy.createDataRequest({ user, type: 'deletion', details: details ? String(details).trim() : null });
|
||||
await prisma.user.update({ where: { id: user.id }, data: { isActive: false, tokenVersion: { increment: 1 }, newsEmail: false, newsWhatsApp: false } });
|
||||
await require('../utils/twoFactor').clearTrustedDevices(user.id);
|
||||
const blockers = await privacy.deletionBlockers(user.id);
|
||||
sendPrivacyEmail(user.email, 'We received your request to delete your account', {
|
||||
name: user.name,
|
||||
heading: 'Deletion request received',
|
||||
paragraphs: [
|
||||
'We received your request to delete your account and personal information. Your account has been closed, so you can no longer log in.',
|
||||
blockers.length
|
||||
? `You still have a registration for ${blockers.map(b => b.eventTitle).join(', ')}. Your information is deleted after that, or sooner if the registration is cancelled. Contact us if you'd like to cancel it.`
|
||||
: 'We will remove your personal information within 30 days and let you know when it is done.',
|
||||
'Records we must keep by law, such as payment amounts and dates, are kept without your name on them.',
|
||||
'If you did not ask for this, contact us immediately.',
|
||||
],
|
||||
});
|
||||
notifyAdminsOfRequest(request).catch(() => {});
|
||||
return request;
|
||||
}
|
||||
|
||||
// ─── The person themselves ────────────────────────────────────────────────────
|
||||
|
||||
// @desc My privacy settings: news opt-in, consent history, my requests
|
||||
// @route GET /api/privacy/me
|
||||
// @access Private
|
||||
const getMyPrivacy = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: { phoneNumber: true, notificationPreference: true, newsEmail: true, newsWhatsApp: true, newsAskedAt: true },
|
||||
});
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
const [history, requests] = await Promise.all([
|
||||
privacy.consentHistory(req.user.id),
|
||||
prisma.dataRequest.findMany({
|
||||
where: { userId: req.user.id, selfService: false },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, type: true, status: true, details: true, response: true, createdAt: true, closedAt: true },
|
||||
}),
|
||||
]);
|
||||
res.json({
|
||||
news: { email: user.newsEmail, whatsapp: user.newsWhatsApp },
|
||||
newsAskedAt: user.newsAskedAt,
|
||||
canWhatsApp: isValidZAPhone(user.phoneNumber),
|
||||
notificationPreference: user.notificationPreference,
|
||||
history,
|
||||
requests,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Change my News & announcements opt-in
|
||||
// @route PUT /api/privacy/me/news
|
||||
// @access Private
|
||||
const updateMyNews = async (req, res) => {
|
||||
try {
|
||||
const { email, whatsapp, source } = req.body || {};
|
||||
// 'prompt' = the one-time question after login; anything else is the privacy centre.
|
||||
const fromPrompt = source === 'prompt';
|
||||
const updated = await privacy.setNewsPreferences(
|
||||
req.user.id,
|
||||
{ email: typeof email === 'boolean' ? email : undefined, whatsapp: typeof whatsapp === 'boolean' ? whatsapp : undefined },
|
||||
{ source: fromPrompt ? 'prompt' : 'privacy_centre', ip: getClientIp(req), answered: true, logAll: fromPrompt },
|
||||
);
|
||||
if (!updated) { res.status(404); throw new Error('User not found'); }
|
||||
res.json({ news: { email: updated.newsEmail, whatsapp: updated.newsWhatsApp }, newsAskedAt: updated.newsAskedAt });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Download my data (JSON, or a readable PDF with ?format=pdf)
|
||||
// @route GET /api/privacy/me/export
|
||||
// @access Private
|
||||
const exportMyData = async (req, res) => {
|
||||
try {
|
||||
// An admin viewing as this person must not walk off with their whole record.
|
||||
if (req.impersonation) { res.status(403); throw new Error('Not available while viewing as another user.'); }
|
||||
const data = await privacy.exportUserData(req.user.id);
|
||||
if (!data) { res.status(404); throw new Error('User not found'); }
|
||||
// Kept as a closed access request: the record that the person got their data.
|
||||
await privacy.createDataRequest({ user: { id: req.user.id, name: data.account.name, email: data.account.email }, type: 'access', selfService: true });
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
if (req.query.format === 'pdf') {
|
||||
const pdf = await privacy.dataExportPdf(data);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="my-data-${stamp}.pdf"`);
|
||||
return res.send(pdf);
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="my-data-${stamp}.json"`);
|
||||
res.send(JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Make a privacy request: correction (details required) or deletion (password required)
|
||||
// @route POST /api/privacy/me/requests
|
||||
// @access Private
|
||||
const createMyRequest = async (req, res) => {
|
||||
try {
|
||||
const { type, details, password } = req.body || {};
|
||||
if (!['correction', 'deletion'].includes(type)) { res.status(400); throw new Error('type must be correction or deletion'); }
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
const open = await prisma.dataRequest.findFirst({ where: { userId: user.id, type, status: 'open' } });
|
||||
if (open) { res.status(409); throw new Error(`You already have an open ${type} request.`); }
|
||||
|
||||
if (type === 'correction') {
|
||||
if (!details || !String(details).trim()) { res.status(400); throw new Error('Tell us what needs correcting.'); }
|
||||
const request = await privacy.createDataRequest({ user, type, details: String(details).trim() });
|
||||
notifyAdminsOfRequest(request).catch(() => {});
|
||||
return res.status(201).json({ request, message: 'Your request was sent. We will get back to you by email.' });
|
||||
}
|
||||
|
||||
if (!(await comparePassword(password || '', user.password))) { res.status(400); throw new Error('Incorrect password'); }
|
||||
const request = await requestAccountDeletion(user, { details });
|
||||
res.status(201).json({ request, message: 'Your account is closed and your deletion request was sent.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Unsubscribe links (no login) ─────────────────────────────────────────────
|
||||
|
||||
// @desc What an unsubscribe link refers to
|
||||
// @route GET /api/privacy/unsubscribe/:token
|
||||
// @access Public (signed token)
|
||||
const getUnsubscribe = async (req, res) => {
|
||||
try {
|
||||
const info = await privacy.describeUnsubscribe(req.params.token);
|
||||
if (!info) { res.status(404); throw new Error('This unsubscribe link is not valid.'); }
|
||||
res.json(info);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Unsubscribe from news (body.channels: ['email','whatsapp'], default both)
|
||||
// @route POST /api/privacy/unsubscribe/:token
|
||||
// @access Public (signed token)
|
||||
const postUnsubscribe = async (req, res) => {
|
||||
try {
|
||||
const channels = Array.isArray(req.body?.channels) ? req.body.channels : undefined;
|
||||
const result = await privacy.unsubscribe(req.params.token, { channels, ip: getClientIp(req) });
|
||||
if (!result) { res.status(404); throw new Error('This unsubscribe link is not valid.'); }
|
||||
res.json({ message: 'You have been unsubscribed.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc One-click unsubscribe (RFC 8058) from the List-Unsubscribe header — email only
|
||||
// @route POST /api/privacy/unsubscribe/:token/one-click
|
||||
// @access Public (signed token)
|
||||
const oneClickUnsubscribe = async (req, res) => {
|
||||
try {
|
||||
await privacy.unsubscribe(req.params.token, { channels: ['email'], ip: getClientIp(req) });
|
||||
} catch { /* mail apps ignore the body; never fail loudly */ }
|
||||
res.status(200).send('Unsubscribed');
|
||||
};
|
||||
|
||||
// @desc A person opening the List-Unsubscribe link in a browser: send them to the page
|
||||
// @route GET /api/privacy/unsubscribe/:token/one-click
|
||||
// @access Public
|
||||
const oneClickRedirect = (req, res) => {
|
||||
res.redirect(302, `${frontendUrl()}/unsubscribe?t=${encodeURIComponent(req.params.token)}`);
|
||||
};
|
||||
|
||||
// ─── Admin: data requests inbox ───────────────────────────────────────────────
|
||||
|
||||
// @desc Data requests (?status=open|closed|all, default open)
|
||||
// @route GET /api/privacy/requests
|
||||
// @access Private/Admin
|
||||
const listRequests = async (req, res) => {
|
||||
try {
|
||||
const status = String(req.query.status || 'open');
|
||||
const where = { selfService: false };
|
||||
if (status === 'open') where.status = 'open';
|
||||
else if (status === 'closed') where.status = { not: 'open' };
|
||||
const rows = await prisma.dataRequest.findMany({
|
||||
where,
|
||||
orderBy: status === 'open' ? { dueAt: 'asc' } : { createdAt: 'desc' },
|
||||
take: 200,
|
||||
include: { handledBy: { select: { name: true } }, user: { select: { isActive: true, phoneNumber: true } } },
|
||||
});
|
||||
const requests = await Promise.all(rows.map(async r => ({
|
||||
id: r.id, type: r.type, status: r.status, name: r.name, email: r.email, details: r.details, response: r.response,
|
||||
dueAt: r.dueAt, closedAt: r.closedAt, createdAt: r.createdAt, userId: r.userId,
|
||||
handledBy: r.handledBy?.name || null,
|
||||
overdue: r.status === 'open' && r.dueAt < new Date(),
|
||||
blockers: r.type === 'deletion' && r.status === 'open' && r.userId ? await privacy.deletionBlockers(r.userId) : [],
|
||||
})));
|
||||
const openCount = status === 'open' ? requests.length : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
|
||||
res.json({ requests, openCount });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
async function loadOpenRequest(req, res) {
|
||||
const request = await prisma.dataRequest.findUnique({ where: { id: req.params.id } });
|
||||
if (!request) { res.status(404); throw new Error('Request not found'); }
|
||||
if (request.status !== 'open') { res.status(409); throw new Error('This request is already closed.'); }
|
||||
return request;
|
||||
}
|
||||
|
||||
// @desc Complete a request. Deletion: anonymises the account (refused while it has a
|
||||
// registration for an upcoming event). body.response is emailed to the person.
|
||||
// @route POST /api/privacy/requests/:id/complete
|
||||
// @access Private/Admin
|
||||
const completeRequest = async (req, res) => {
|
||||
try {
|
||||
const request = await loadOpenRequest(req, res);
|
||||
const response = req.body?.response ? String(req.body.response).trim().slice(0, 5000) : null;
|
||||
if (request.type === 'correction' && !response) { res.status(400); throw new Error('Say what was corrected — it is emailed to the person.'); }
|
||||
|
||||
let counts = null;
|
||||
if (request.type === 'deletion' && request.userId) {
|
||||
const blockers = await privacy.deletionBlockers(request.userId);
|
||||
if (blockers.length) {
|
||||
res.status(409);
|
||||
throw new Error(`Cancel (and refund where needed) these registrations first: ${blockers.map(b => b.eventTitle).join(', ')}.`);
|
||||
}
|
||||
counts = await privacy.anonymiseUser(request.userId);
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'user_anonymised', targetType: 'user', targetId: request.userId,
|
||||
metadata: { dataRequestId: request.id, ...counts }, ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
const closed = await prisma.dataRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { status: 'completed', response, closedAt: new Date(), handledById: req.user.id },
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'data_request_completed', targetType: 'data_request', targetId: request.id,
|
||||
metadata: { type: request.type, name: request.name }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
sendPrivacyEmail(request.email, request.type === 'deletion' ? 'Your personal information has been deleted' : 'Your privacy request has been completed', {
|
||||
name: request.name,
|
||||
heading: request.type === 'deletion' ? 'Your information has been deleted' : 'Request completed',
|
||||
paragraphs: [
|
||||
request.type === 'deletion'
|
||||
? 'Your account and personal information have been deleted. Records we must keep by law, such as payment amounts and dates, are kept without your name on them. Backups that still contain your information are deleted as they age out.'
|
||||
: `We have completed your request for ${REQUEST_LABEL[request.type]}.`,
|
||||
...(response ? [response] : []),
|
||||
],
|
||||
});
|
||||
res.json({ request: closed, anonymised: counts });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reject or cancel a request (body.response says why; emailed). Rejecting a
|
||||
// deletion request opens the account again (e.g. the person changed their mind).
|
||||
// @route POST /api/privacy/requests/:id/reject
|
||||
// @access Private/Admin
|
||||
const rejectRequest = async (req, res) => {
|
||||
try {
|
||||
const request = await loadOpenRequest(req, res);
|
||||
const response = req.body?.response ? String(req.body.response).trim().slice(0, 5000) : '';
|
||||
if (!response) { res.status(400); throw new Error('Give a reason — it is emailed to the person.'); }
|
||||
const cancelled = req.body?.cancelled === true;
|
||||
|
||||
if (request.type === 'deletion' && request.userId) {
|
||||
await prisma.user.update({ where: { id: request.userId }, data: { isActive: true } });
|
||||
}
|
||||
const closed = await prisma.dataRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { status: cancelled ? 'cancelled' : 'rejected', response, closedAt: new Date(), handledById: req.user.id },
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'data_request_rejected', targetType: 'data_request', targetId: request.id,
|
||||
metadata: { type: request.type, name: request.name, cancelled, reactivated: request.type === 'deletion' }, ip: getClientIp(req),
|
||||
});
|
||||
sendPrivacyEmail(request.email, cancelled ? 'Your privacy request was cancelled' : 'About your privacy request', {
|
||||
name: request.name,
|
||||
heading: cancelled ? 'Request cancelled' : 'About your request',
|
||||
paragraphs: [
|
||||
`Your request for ${REQUEST_LABEL[request.type]} was ${cancelled ? 'cancelled' : 'not carried out'}.`,
|
||||
response,
|
||||
...(request.type === 'deletion' ? ['Your account is open again — you can log in as before.'] : []),
|
||||
...(cancelled ? [] : ['If you disagree, you may complain to the Information Regulator (inforegulator.org.za).']),
|
||||
],
|
||||
});
|
||||
res.json({ request: closed });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMyPrivacy,
|
||||
updateMyNews,
|
||||
exportMyData,
|
||||
createMyRequest,
|
||||
getUnsubscribe,
|
||||
postUnsubscribe,
|
||||
oneClickUnsubscribe,
|
||||
oneClickRedirect,
|
||||
listRequests,
|
||||
completeRequest,
|
||||
rejectRequest,
|
||||
notifyAdminsOfRequest,
|
||||
requestAccountDeletion,
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
const prisma = require('../config/db');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { getProvider } = require('../payments');
|
||||
|
||||
const canReconcile = (req) => !!req.user && ['admin', 'supervisor'].includes(req.user.role);
|
||||
// The checkout metadata (who/what the payment was for), read by the provider that sent it.
|
||||
const metadataOf = (tx) => {
|
||||
const provider = getProvider(tx.provider);
|
||||
try { return (provider && tx.raw ? provider.parseWebhook(tx.raw).transaction.metadata : null) || {}; } catch { return {}; }
|
||||
};
|
||||
const withLabel = (tx) => ({ ...tx, providerLabel: getProvider(tx.provider)?.label || tx.provider, metadata: metadataOf(tx) });
|
||||
|
||||
// @desc Payment provider transactions (optionally filter by provider, reconciled, ignored)
|
||||
// @route GET /api/provider-transactions (also /api/yoco-transactions)
|
||||
// @access Private/Admin or Supervisor
|
||||
const getAllProviderTransactions = async (req, res) => {
|
||||
try {
|
||||
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
|
||||
const { reconciled, ignored, provider } = req.query;
|
||||
const where = {};
|
||||
if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true';
|
||||
if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true';
|
||||
if (provider) where.provider = String(provider);
|
||||
|
||||
const items = await prisma.providerTransaction.findMany({ where, orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }] });
|
||||
return res.status(200).json({ success: true, data: items.map(withLabel) });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch provider transactions:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Unreconciled provider transactions (not reconciled and not ignored)
|
||||
// @route GET /api/provider-transactions/unreconciled (also /api/yoco-transactions/unreconciled)
|
||||
// @access Private/Admin or Supervisor
|
||||
const getUnreconciledProviderTransactions = async (req, res) => {
|
||||
try {
|
||||
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
|
||||
const items = await prisma.providerTransaction.findMany({
|
||||
where: { reconciled: false, ignored: false, ...(req.query.provider ? { provider: String(req.query.provider) } : {}) },
|
||||
orderBy: { createdDate: 'desc' },
|
||||
});
|
||||
return res.status(200).json({ success: true, data: items.map(withLabel) });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unreconciled provider transactions:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reconcile a provider transaction to a Registration or Donation (creates Payment)
|
||||
// @route POST /api/provider-transactions/:id/reconcile (also /api/yoco-transactions/:id/reconcile)
|
||||
// @access Private/Admin or Supervisor
|
||||
const reconcileProviderTransaction = async (req, res) => {
|
||||
try {
|
||||
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
|
||||
const { id } = req.params;
|
||||
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
|
||||
|
||||
const tx = await prisma.providerTransaction.findUnique({ where: { id } });
|
||||
if (!tx) return res.status(404).json({ success: false, message: 'Transaction not found' });
|
||||
if (tx.reconciled && tx.paymentId) {
|
||||
return res.status(409).json({ success: false, message: 'Already reconciled', data: withLabel(tx) });
|
||||
}
|
||||
|
||||
// Load registration if provided
|
||||
let registration = null;
|
||||
if (registrationId) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
event: true,
|
||||
user: true,
|
||||
registrationOptions: { include: { eventOption: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
if (!registration) return res.status(404).json({ success: false, message: 'Registration not found' });
|
||||
}
|
||||
|
||||
// Determine payment fields
|
||||
const amountFloat = typeof tx.amount === 'number' ? (tx.amount / 100) : 0;
|
||||
if (!amountFloat || amountFloat <= 0) {
|
||||
return res.status(400).json({ success: false, message: 'Invalid amount on the transaction for reconciliation' });
|
||||
}
|
||||
|
||||
const metadata = metadataOf(tx);
|
||||
|
||||
// Resolve a valid userId for the Payment to satisfy FK constraints
|
||||
let resolvedUserId = null;
|
||||
if (registration?.userId) {
|
||||
resolvedUserId = registration.userId;
|
||||
} else if (bodyUserId) {
|
||||
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
|
||||
// over metadata guesswork, but still verify the user actually exists.
|
||||
try {
|
||||
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
|
||||
if (exists) resolvedUserId = String(bodyUserId);
|
||||
} catch {}
|
||||
}
|
||||
if (!resolvedUserId && metadata.userId) {
|
||||
try {
|
||||
const exists = await prisma.user.findUnique({ where: { id: String(metadata.userId) } });
|
||||
if (exists) resolvedUserId = String(metadata.userId);
|
||||
} catch {}
|
||||
}
|
||||
if (!resolvedUserId && req.user?.id) {
|
||||
// Fallback to the acting supervisor/admin to avoid FK violations
|
||||
resolvedUserId = req.user.id;
|
||||
}
|
||||
if (!resolvedUserId) {
|
||||
return res.status(400).json({ success: false, message: 'Unable to resolve a valid user for this payment' });
|
||||
}
|
||||
|
||||
const paymentData = {
|
||||
amount: amountFloat,
|
||||
method: tx.methodType || 'card',
|
||||
provider: tx.provider,
|
||||
userId: resolvedUserId,
|
||||
recordedById: req.user?.id || null, // staff who performed the reconciliation
|
||||
registrationId: registration?.id || null,
|
||||
eventId: registration?.eventId || eventId || null,
|
||||
isDonation: !registration?.id,
|
||||
externalId: tx.externalId || undefined,
|
||||
status: 'completed'
|
||||
};
|
||||
|
||||
// Ensure donation has an eventId
|
||||
if (!registration && !paymentData.eventId) {
|
||||
return res.status(400).json({ success: false, message: 'eventId is required when reconciling as donation' });
|
||||
}
|
||||
|
||||
// Keep the provider's creation time so early-bird pricing is evaluated correctly
|
||||
const createdAt = tx.createdDate || tx.createdAt || new Date();
|
||||
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
|
||||
|
||||
// Update registration status and generate tickets if paid. Ticket generation stays
|
||||
// synchronous so `generatedTickets` can be in the response; emailing is backgrounded below.
|
||||
let generatedTickets = [];
|
||||
if (registration) {
|
||||
try {
|
||||
const updatedReg = await updateRegistrationStatus(registration.id);
|
||||
if (updatedReg && updatedReg.status === 'paid') {
|
||||
try {
|
||||
generatedTickets = await generateTicketsForRegistration(registration.id);
|
||||
} catch (genErr) {
|
||||
console.error('Error generating tickets after reconciliation:', genErr);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// log but do not fail reconciliation
|
||||
console.warn('Failed to update registration after reconciliation:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
const _rxPaymentId = payment.id;
|
||||
const _rxUserId = registration?.userId;
|
||||
const _rxRegId = registration?.id;
|
||||
const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0;
|
||||
(async () => {
|
||||
try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); }
|
||||
if (_rxShouldEmailTickets && _rxUserId) {
|
||||
const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after reconciliation:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
const updatedTx = await prisma.providerTransaction.update({ where: { id: tx.id }, data: { reconciled: true, paymentId: payment.id } });
|
||||
|
||||
return res.status(200).json({ success: true, data: { transaction: withLabel(updatedTx), payment, generatedTickets: generatedTickets.length ? generatedTickets : undefined } });
|
||||
} catch (error) {
|
||||
console.error('Failed to reconcile provider transaction:', error);
|
||||
// A Payment with the same externalId already exists: link to it instead
|
||||
if (error?.code === 'P2002') {
|
||||
try {
|
||||
const tx = await prisma.providerTransaction.findUnique({ where: { id: req.params.id } });
|
||||
const existing = tx ? await prisma.payment.findFirst({ where: { externalId: tx.externalId } }) : null;
|
||||
if (existing) {
|
||||
const updatedTx = await prisma.providerTransaction.update({ where: { id: tx.id }, data: { reconciled: true, paymentId: existing.id } });
|
||||
return res.status(200).json({ success: true, data: { transaction: withLabel(updatedTx), payment: existing }, message: 'Linked to existing payment' });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// Minimal registration status update mirroring webhook logic
|
||||
async function updateRegistrationStatus(registrationId) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
if (!registration) return null;
|
||||
const totalPaid = (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = require('../utils/pricing').computeRegistrationTotalDue(registration, new Date());
|
||||
let newStatus = 'pending';
|
||||
if (totalPaid >= totalDue) newStatus = 'paid';
|
||||
else if (totalPaid > 0) newStatus = 'partial_paid';
|
||||
return prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
|
||||
}
|
||||
|
||||
// @desc Ignore a provider transaction (mark as ignored and reconciled without creating a payment)
|
||||
// @route POST /api/provider-transactions/:id/ignore (also /api/yoco-transactions/:id/ignore)
|
||||
// @access Private/Admin or Supervisor
|
||||
const ignoreProviderTransaction = async (req, res) => {
|
||||
try {
|
||||
if (!canReconcile(req)) return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
const { id } = req.params;
|
||||
const tx = await prisma.providerTransaction.findUnique({ where: { id } });
|
||||
if (!tx) return res.status(404).json({ success: false, message: 'Transaction not found' });
|
||||
|
||||
if (tx.reconciled && tx.ignored) {
|
||||
return res.status(200).json({ success: true, data: withLabel(tx), message: 'Already ignored' });
|
||||
}
|
||||
|
||||
const updated = await prisma.providerTransaction.update({ where: { id }, data: { ignored: true, reconciled: true } });
|
||||
return res.status(200).json({ success: true, data: withLabel(updated) });
|
||||
} catch (error) {
|
||||
console.error('Failed to ignore provider transaction:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllProviderTransactions,
|
||||
getUnreconciledProviderTransactions,
|
||||
reconcileProviderTransaction,
|
||||
ignoreProviderTransaction
|
||||
};
|
||||
@@ -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, mergeSessionIds } = 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,21 @@ 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 });
|
||||
// (An all-sessions ticket with sessions ticked is checked per session instead.)
|
||||
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && 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, undefined, { excludeEntryId: waitlistOffer?.id });
|
||||
|
||||
// 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 +250,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 +270,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 +331,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([
|
||||
@@ -336,7 +348,7 @@ const createRegistration = async (req, res) => {
|
||||
}),
|
||||
prisma.registrationOption.update({
|
||||
where: { id: existing.id },
|
||||
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }
|
||||
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: mergeSessionIds(existing.sessionIds, opt.sessionIds) }) }
|
||||
})
|
||||
]);
|
||||
} else {
|
||||
@@ -347,6 +359,8 @@ const createRegistration = async (req, res) => {
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
sessionIds: opt.sessionIds || [],
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -394,6 +408,8 @@ const createRegistration = async (req, res) => {
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
sessionIds: option.sessionIds || [],
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -429,9 +445,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 +457,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 +465,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 +540,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 +621,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 +673,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 +918,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 +956,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 +977,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 +994,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 +1005,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 +1177,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([
|
||||
@@ -1166,6 +1196,8 @@ const createManualRegistration = async (req, res) => {
|
||||
quantity: existing.quantity + opt.quantity,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
// More of an all-sessions ticket: it covers the sessions of both bookings.
|
||||
...(existing.sessionId ? {} : { sessionIds: mergeSessionIds(existing.sessionIds, opt.sessionIds) }),
|
||||
}
|
||||
})
|
||||
]);
|
||||
@@ -1177,6 +1209,8 @@ const createManualRegistration = async (req, res) => {
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
sessionIds: opt.sessionIds || [],
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -1225,6 +1259,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 +1279,8 @@ const createManualRegistration = async (req, res) => {
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
sessionIds: option.sessionIds || [],
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: {
|
||||
@@ -1262,6 +1300,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 +1308,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 +1317,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1322,20 +1365,20 @@ const createManualRegistration = async (req, res) => {
|
||||
await generateTicketsForRegistration(registration.id);
|
||||
} catch {}
|
||||
|
||||
// For paid registrations, generate a Yoco checkout link to include in the email
|
||||
// For paid registrations, generate a checkout link to include in the email
|
||||
let yocoPaymentUrl = null;
|
||||
if (registration.status !== 'paid') {
|
||||
try {
|
||||
const { createRegistrationCheckoutInternal } = require('./paymentController');
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts');
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const checkout = await createRegistrationCheckoutInternal(registrationId, userId, {
|
||||
const checkout = await createRegistrationCheckout(registrationId, userId, {
|
||||
successUrl: `${baseUrl}/payment/success`,
|
||||
cancelUrl: `${baseUrl}/payment/cancel`,
|
||||
failureUrl: `${baseUrl}/payment/failure`,
|
||||
});
|
||||
yocoPaymentUrl = checkout.redirectUrl || null;
|
||||
} catch (e) {
|
||||
console.warn('Could not create Yoco checkout for self-service email:', e?.message || e);
|
||||
console.warn('Could not create a checkout for the self-service email:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,6 +1490,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 +1500,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 +1611,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 +1771,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 +2179,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)}.` });
|
||||
|
||||
@@ -397,6 +397,13 @@ function writeTableRows(ws, startRow, columns, rows) {
|
||||
return { nextRow: r, dataStartRow };
|
||||
}
|
||||
|
||||
// POPIA access log: who took which report out of the system, and how many rows.
|
||||
function logReport(req, action) {
|
||||
const { logDataAccess, reportRowCount } = require('../utils/accessLog');
|
||||
const p = req.body || {};
|
||||
logDataAccess(req, { action, what: [p.title || 'Report', p.subtitle].filter(Boolean).join(' — '), records: reportRowCount(p) });
|
||||
}
|
||||
|
||||
// POST /api/reports/pdf
|
||||
// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
|
||||
const generatePdf = async (req, res) => {
|
||||
@@ -410,6 +417,7 @@ const generatePdf = async (req, res) => {
|
||||
doc.pipe(res);
|
||||
drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables });
|
||||
doc.end();
|
||||
logReport(req, 'report_pdf');
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
@@ -426,6 +434,7 @@ const generateExcel = async (req, res) => {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
await wb.xlsx.write(res);
|
||||
res.end();
|
||||
logReport(req, 'report_excel');
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
}
|
||||
@@ -462,6 +471,7 @@ const emailPdf = async (req, res) => {
|
||||
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
|
||||
logReport(req, 'report_email');
|
||||
res.json({ message: `Report emailed to ${user.email}` });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
@@ -489,6 +499,7 @@ const whatsappPdf = async (req, res) => {
|
||||
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
|
||||
await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report');
|
||||
|
||||
logReport(req, 'report_whatsapp');
|
||||
res.json({ message: `Report sent to your WhatsApp` });
|
||||
} catch (e) {
|
||||
res.status(400).json({ message: e.message });
|
||||
|
||||
@@ -8,9 +8,12 @@ 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']);
|
||||
// Payment provider settings are only readable (secrets masked) and writable through
|
||||
// /api/yoco and /api/payfast — never through the generic settings endpoints.
|
||||
const PAYMENT_PRIVATE_KEYS = new Set([
|
||||
'yoco_secret_key', 'yoco_webhook_secret', 'yoco_webhook_id',
|
||||
'payfast_merchant_id', 'payfast_merchant_key', 'payfast_passphrase', 'payfast_mode',
|
||||
]);
|
||||
|
||||
const PUBLIC_KEYS = [
|
||||
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
|
||||
@@ -21,6 +24,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',
|
||||
@@ -48,7 +53,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 (PAYMENT_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
|
||||
@@ -84,7 +89,7 @@ const updateSettings = async (req, res) => {
|
||||
}
|
||||
|
||||
const ops = Object.entries(updates)
|
||||
.filter(([k, v]) => v !== undefined && v !== null && !YOCO_PRIVATE_KEYS.has(k))
|
||||
.filter(([k, v]) => v !== undefined && v !== null && !PAYMENT_PRIVATE_KEYS.has(k))
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
|
||||
@@ -123,7 +128,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 && !YOCO_PRIVATE_KEYS.has(k)) },
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PAYMENT_PRIVATE_KEYS.has(k)) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
@@ -200,7 +205,7 @@ const runSetup = async (req, res) => {
|
||||
|
||||
const toSave = { ...settings, setup_complete: 'true' };
|
||||
const ops = Object.entries(toSave)
|
||||
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !YOCO_PRIVATE_KEYS.has(k))
|
||||
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PAYMENT_PRIVATE_KEYS.has(k))
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const axios = require('axios');
|
||||
const { yocoAuthHeader } = require('../utils/yoco');
|
||||
const payments = require('../payments');
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
|
||||
@@ -45,7 +44,7 @@ const getEventSponsorship = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Start a Yoco checkout to sponsor spots (or a custom amount) at an event
|
||||
// @desc Start an online checkout to sponsor spots (or a custom amount) at an event
|
||||
// @route POST /api/sponsorships/checkout { eventId, spots? | amount?, anonymous?, sponsorName?, message?, name?, email?, phone? }
|
||||
// @access Public (guests give a name and email for the receipt)
|
||||
const createSponsorCheckout = async (req, res) => {
|
||||
@@ -72,9 +71,8 @@ const createSponsorCheckout = async (req, res) => {
|
||||
const back = `${base}/events/${encodeURIComponent(event.id)}`;
|
||||
const amountInCents = Math.round(charge.amount * 100);
|
||||
const label = charge.spots ? `Sponsor ${charge.spots} spot${charge.spots > 1 ? 's' : ''} at ${event.title}` : `Sponsorship for ${event.title}`;
|
||||
const checkoutData = {
|
||||
amount: amountInCents,
|
||||
currency: 'ZAR',
|
||||
const checkout = await payments.createCheckout(req.body?.provider || null, {
|
||||
amountCents: amountInCents,
|
||||
metadata: {
|
||||
type: 'sponsorship', isDonation: true, eventId: event.id, eventTitle: event.title, userId: payer.id,
|
||||
sponsorAnonymous: isAnonymous ? 'true' : 'false',
|
||||
@@ -85,14 +83,14 @@ const createSponsorCheckout = async (req, res) => {
|
||||
successUrl: `${back}?sponsored=1`,
|
||||
cancelUrl: back,
|
||||
failureUrl: `${back}?sponsored=failed`,
|
||||
lineItems: [{ displayName: label, quantity: 1, pricingDetails: { price: amountInCents } }],
|
||||
};
|
||||
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, {
|
||||
headers: { 'Content-Type': 'application/json', Authorization: await yocoAuthHeader(), 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
|
||||
lineItems: [{ name: label, quantity: 1, unitCents: amountInCents }],
|
||||
description: label,
|
||||
payer: { name: payer.name || '', email: payer.email || '' },
|
||||
idempotencyKey: `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}`,
|
||||
});
|
||||
res.json({ redirectUrl: response.data.redirectUrl, checkoutId: response.data.id, amount: charge.amount });
|
||||
res.json({ redirectUrl: checkout.redirectUrl, checkoutId: checkout.checkoutId, provider: checkout.provider, amount: charge.amount });
|
||||
} catch (error) {
|
||||
if (error.response) console.error('Sponsor checkout error:', error.response.data || error.message);
|
||||
if (error.statusCode >= 500) console.error('Sponsor checkout error:', error.message);
|
||||
fail(res, 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,15 @@ const getScanPreview = async (req, res) => {
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
ro.id AS "registrationOptionId",
|
||||
ro."sessionId" AS "pinnedSessionId",
|
||||
ro."sessionIds" AS "chosenSessionIds",
|
||||
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 +279,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 +308,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, new Date(), r.chosenSessionIds);
|
||||
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 +343,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 +363,9 @@ const scanTicket = async (req, res) => {
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
e.title AS "eventTitle",
|
||||
e."scheduleType" AS "scheduleType",
|
||||
ro."sessionId" AS "pinnedSessionId",
|
||||
ro."sessionIds" AS "chosenSessionIds",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
eo."isMainTicket" AS "isMainTicket",
|
||||
@@ -345,7 +374,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 +386,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 +435,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, new Date(), r.chosenSessionIds);
|
||||
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 +506,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 +524,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 +549,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 +903,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, null, ticket.registrationOption.sessionIds)
|
||||
|| (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 +984,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 +1070,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 +1093,13 @@ 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);
|
||||
// All-sessions tickets with the same sessions ticked: those sessions.
|
||||
const chosenKeys = new Set(tickets.map(t => (t.registrationOption?.sessionIds || []).join(',')));
|
||||
const chosenIds = chosenKeys.size === 1 && !pinnedSessions.length ? tickets[0].registrationOption?.sessionIds : null;
|
||||
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, chosenIds) || (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 = [];
|
||||
|
||||
|
||||
@@ -139,7 +139,8 @@ async function sendWelcomeEmail(user) {
|
||||
// Fields the login flow needs once the password (and any 2FA code) has checked out.
|
||||
const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
|
||||
noticeVersionAccepted: true,
|
||||
};
|
||||
|
||||
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
|
||||
@@ -150,7 +151,9 @@ function errorStatus(error, res) {
|
||||
|
||||
// Final step of a successful login (after the password, and the 2FA code when
|
||||
// required): notify, record the activity, and issue the session token.
|
||||
function completeLogin(user, req, res, extra = {}) {
|
||||
async function completeLogin(user, req, res, extra = {}) {
|
||||
const noticePromptPending = await require('../utils/privacyNotice').noticePromptPending(user).catch(() => false);
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(user, req).catch(() => {});
|
||||
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
@@ -161,6 +164,10 @@ function completeLogin(user, req, res, extra = {}) {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion),
|
||||
// POPIA: ask about News & announcements once (see NewsPrompt on the frontend), and to
|
||||
// accept a privacy notice version marked as a significant change (NoticePrompt).
|
||||
newsPromptPending: !user.newsAskedAt,
|
||||
noticePromptPending,
|
||||
...extra
|
||||
});
|
||||
}
|
||||
@@ -170,7 +177,7 @@ function completeLogin(user, req, res, extra = {}) {
|
||||
// @access Public
|
||||
const registerUser = async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, phoneNumber, notificationPreference } = req.body;
|
||||
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = req.body;
|
||||
|
||||
// Normalize phone using SA-aware normalisation
|
||||
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
|
||||
@@ -215,6 +222,18 @@ const registerUser = async (req, res) => {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// POPIA: the sign-up form's (unticked) "news and announcements" box. Recorded either
|
||||
// way, so they aren't asked again at their first login.
|
||||
await require('../utils/privacy').setNewsPreferences(
|
||||
user.id,
|
||||
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
|
||||
{ source: 'signup', ip: getClientIp(req), answered: true, logAll: true },
|
||||
).catch(e => console.warn('[signup] news preference not saved:', e?.message || e));
|
||||
// The sign-up form requires ticking "I agree to the Terms of Use and Privacy Policy".
|
||||
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'signup', ip: getClientIp(req) })
|
||||
.catch(e => console.warn('[signup] notice acceptance not saved:', e?.message || e));
|
||||
|
||||
|
||||
// Send welcome email in the background — don't block the response
|
||||
sendWelcomeEmail(user).catch(() => {});
|
||||
|
||||
@@ -340,7 +359,7 @@ const loginUser = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
completeLogin(updated, req, res);
|
||||
await completeLogin(updated, req, res);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -375,7 +394,7 @@ const verifyLoginTwoFactor = async (req, res) => {
|
||||
if (rememberDevice === true) {
|
||||
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
|
||||
}
|
||||
completeLogin(user, req, res, extra);
|
||||
await completeLogin(user, req, res, extra);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -515,12 +534,18 @@ const getUserProfile = async (req, res) => {
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
newsAskedAt: true,
|
||||
noticeVersionAccepted: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
res.json({ ...user, twoFactorRequired: requiresTwoFactor(user) });
|
||||
const { newsAskedAt, noticeVersionAccepted, ...rest } = user;
|
||||
// newsPromptPending: they haven't answered the news opt-in question yet (asked once).
|
||||
// noticePromptPending: a privacy notice version they must accept has been published.
|
||||
const noticePromptPending = !req.impersonation && await require('../utils/privacyNotice').noticePromptPending({ noticeVersionAccepted });
|
||||
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation, noticePromptPending });
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
@@ -874,21 +899,22 @@ const anonymizeUser = async (req, res) => {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: 'Deleted User',
|
||||
email: `deleted-${req.params.id}@deleted.local`,
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
}
|
||||
const { deletionBlockers, anonymiseUser } = require('../utils/privacy');
|
||||
const blockers = await deletionBlockers(user.id);
|
||||
if (blockers.length) {
|
||||
res.status(409);
|
||||
throw new Error(`Cancel (and refund where needed) these registrations first: ${blockers.map(b => b.eventTitle).join(', ')}.`);
|
||||
}
|
||||
const counts = await anonymiseUser(user.id);
|
||||
// An open deletion request from this person is now done.
|
||||
await prisma.dataRequest.updateMany({
|
||||
where: { userId: user.id, type: 'deletion', status: 'open' },
|
||||
data: { status: 'completed', closedAt: new Date(), handledById: req.user.id },
|
||||
});
|
||||
require('../utils/adminAudit').logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'user_anonymised', targetType: 'user', targetId: user.id,
|
||||
metadata: counts, ip: getClientIp(req),
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.params.id);
|
||||
|
||||
res.json({ message: 'User data deleted' });
|
||||
} catch (error) {
|
||||
@@ -1134,34 +1160,11 @@ const closeAccount = async (req, res) => {
|
||||
const realName = user.name;
|
||||
|
||||
if (deleteData) {
|
||||
// Anonymise: wipe all personal data while keeping the row intact for referential integrity
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
name: 'Deleted User',
|
||||
email: `deleted-${uuidv4()}@deleted.invalid`,
|
||||
password: '',
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.user.id);
|
||||
// Send closure confirmation to the real address (before it was wiped)
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
const { waText } = require('../utils/notify');
|
||||
const { buildWAAccountClosed } = require('../utils/waMessages');
|
||||
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
|
||||
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
|
||||
// WhatsApp while we still have phone (send before data wipe completes in-flight)
|
||||
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
|
||||
}
|
||||
return res.json({ message: 'Your account and personal data have been removed.' });
|
||||
// POPIA: deleting personal data is a request an admin approves (see privacyController);
|
||||
// the account is closed straight away.
|
||||
const open = await prisma.dataRequest.findFirst({ where: { userId: user.id, type: 'deletion', status: 'open' } });
|
||||
if (!open) await require('./privacyController').requestAccountDeletion(user);
|
||||
return res.json({ message: 'Your account is closed. Your personal information will be deleted once your request has been processed — we will email you.' });
|
||||
} else {
|
||||
// Soft-deactivate only
|
||||
await prisma.user.update({
|
||||
|
||||
@@ -2,8 +2,8 @@ const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { remainingFor } = require('../utils/stock');
|
||||
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken } = require('../utils/waitlist');
|
||||
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken, placesFree, lineName } = require('../utils/waitlist');
|
||||
const { activeSessions, isPickOption, sessionLabel } = require('../utils/sessions');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
@@ -11,19 +11,21 @@ const ENTRY_INCLUDE = {
|
||||
event: { select: { id: true, title: true, startDate: true, picture: true } },
|
||||
eventOption: { select: { id: true, name: true } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
session: { select: { id: true, label: true, startsAt: true, endsAt: true } },
|
||||
};
|
||||
|
||||
// @desc Join the waitlist for a sold-out option
|
||||
// @route POST /api/waitlist
|
||||
// @access Public (login needed when the event requires it)
|
||||
// Body: { eventId, eventOptionId, variantId?, quantity, guestName?, guestEmail?, guestPhone? }
|
||||
// Body: { eventId, eventOptionId, variantId?, sessionId?, quantity, guestName?, guestEmail?, guestPhone? }
|
||||
// sessionId: required for a "pick sessions" option (one session per entry), not allowed otherwise.
|
||||
const joinWaitlist = async (req, res) => {
|
||||
try {
|
||||
const { eventId, eventOptionId, variantId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
|
||||
const { eventId, eventOptionId, variantId = null, sessionId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
|
||||
const qty = parseInt(quantity, 10);
|
||||
if (!eventId || !eventOptionId || !(qty >= 1)) { res.status(400); throw new Error('eventId, eventOptionId and a quantity of at least 1 are required'); }
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } } } });
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } }, sessions: true } });
|
||||
if (!event || !event.isActive) { res.status(404); throw new Error('Event not found'); }
|
||||
if (!event.waitlistEnabled) { res.status(400); throw new Error('This event does not have a waitlist.'); }
|
||||
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) { res.status(400); throw new Error('This event is no longer taking registrations.'); }
|
||||
@@ -32,7 +34,16 @@ const joinWaitlist = async (req, res) => {
|
||||
const variant = variantId ? (option.variants || []).find(v => v.id === variantId) : null;
|
||||
if (variantId && !variant) { res.status(400); throw new Error('Unknown variant for this option'); }
|
||||
|
||||
const free = await remainingFor(option, variant);
|
||||
if (isPickOption(event, option)) {
|
||||
if (!sessionId) { res.status(400); throw new Error(`Choose which session of "${option.name}" you want to wait for.`); }
|
||||
const session = activeSessions(event.sessions).find(s => s.id === sessionId);
|
||||
if (!session) { res.status(400); throw new Error('That session isn\'t available.'); }
|
||||
if (new Date(session.startsAt) <= new Date()) { res.status(400); throw new Error(`${sessionLabel(session, event.sessions)} has already started.`); }
|
||||
} else if (sessionId) {
|
||||
res.status(400); throw new Error(`"${option.name}" covers every session — no session to choose.`);
|
||||
}
|
||||
|
||||
const free = await placesFree(event, option, variant, sessionId);
|
||||
if (free === null || free >= qty) { res.status(400); throw new Error('There are places available — you can register now.'); }
|
||||
|
||||
// Who's joining
|
||||
@@ -47,15 +58,15 @@ const joinWaitlist = async (req, res) => {
|
||||
})).id;
|
||||
}
|
||||
|
||||
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, status: { in: ACTIVE } } });
|
||||
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, sessionId, status: { in: ACTIVE } } });
|
||||
if (existing) { res.status(409); throw new Error('You\'re already on the waitlist for this.'); }
|
||||
|
||||
const last = await prisma.waitlistEntry.aggregate({ where: { eventId }, _max: { position: true } });
|
||||
const entry = await prisma.waitlistEntry.create({
|
||||
data: { eventId, eventOptionId, variantId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
|
||||
data: { eventId, eventOptionId, variantId, sessionId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
|
||||
include: ENTRY_INCLUDE,
|
||||
});
|
||||
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, status: 'waiting', position: { lt: entry.position } } });
|
||||
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, sessionId, status: 'waiting', position: { lt: entry.position } } });
|
||||
res.status(201).json({ ...entry, ahead, message: `You're on the waitlist${ahead ? ` — ${ahead} ahead of you` : ''}. We'll email or WhatsApp you if a place opens up.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
@@ -106,11 +117,15 @@ const claimInfo = async (req, res) => {
|
||||
status: expired ? 'expired' : 'offered',
|
||||
eventId: entry.eventId,
|
||||
eventTitle: entry.event.title,
|
||||
eventStart: entry.event.startDate,
|
||||
eventStart: entry.session?.startsAt || entry.event.startDate,
|
||||
eventOptionId: entry.eventOptionId,
|
||||
optionName: entry.eventOption.name,
|
||||
variantId: entry.variantId,
|
||||
variantName: entry.variant?.name || null,
|
||||
sessionId: entry.sessionId,
|
||||
sessionName: entry.session ? sessionLabel(entry.session, entry.event.sessions) : null,
|
||||
sessionStart: entry.session?.startsAt || null,
|
||||
lineName: lineName(entry),
|
||||
quantity: entry.offeredQuantity,
|
||||
requested: entry.quantity,
|
||||
expiresAt: entry.offerExpiresAt,
|
||||
@@ -142,8 +157,11 @@ const eventWaitlist = async (req, res) => {
|
||||
// @access Private/Supervisor
|
||||
const offerManually = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id }, include: { session: true } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (entry.session && (entry.session.cancelledAt || new Date(entry.session.startsAt) <= new Date())) {
|
||||
res.status(400); throw new Error(`The session they're waiting for has ${entry.session.cancelledAt ? 'been cancelled' : 'already started'}.`);
|
||||
}
|
||||
if (!['waiting', 'expired'].includes(entry.status)) { res.status(400); throw new Error(`This person is ${entry.status === 'offered' ? 'already holding an offer' : `no longer waiting (${entry.status})`}.`); }
|
||||
const qty = Math.max(1, Math.min(entry.quantity, parseInt(req.body?.quantity, 10) || entry.quantity));
|
||||
const updated = await makeOffer(entry.id, qty);
|
||||
|
||||
@@ -1,518 +1,9 @@
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
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');
|
||||
const { handleProviderWebhook } = require('../payments/webhook');
|
||||
|
||||
// @desc Handle Yoco webhook events
|
||||
// @desc Handle Yoco webhook events (signature check and processing: src/payments)
|
||||
// @route POST /api/webhooks/yoco
|
||||
// @access Public
|
||||
const handleYocoWebhook = async (req, res) => {
|
||||
try {
|
||||
const headers = req.headers;
|
||||
const requestBody = req.rawBody;
|
||||
|
||||
// Log every JSON request body for webhook controller
|
||||
try {
|
||||
const contentType = headers['content-type'] || headers['Content-Type'] || '';
|
||||
let parsedBody = null;
|
||||
if (typeof requestBody === 'string') {
|
||||
try {
|
||||
parsedBody = JSON.parse(requestBody);
|
||||
} catch (e) {
|
||||
// Not valid JSON; keep as raw string
|
||||
}
|
||||
} else if (requestBody && typeof requestBody === 'object') {
|
||||
parsedBody = requestBody;
|
||||
}
|
||||
} catch (logErr) {
|
||||
// Fail-safe: never block webhook processing due to logging issues
|
||||
console.error('Failed to log webhook JSON request:', logErr);
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
const id = headers['webhook-id'];
|
||||
const timestamp = headers['webhook-timestamp'];
|
||||
|
||||
const rawSigHeader = headers['webhook-signature'];
|
||||
if (!id || !timestamp || !rawSigHeader) {
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: 'Missing webhook headers',
|
||||
details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) }
|
||||
};
|
||||
return res.status(400).json(responseObj);
|
||||
}
|
||||
|
||||
// Reject replayed requests (Yoco recommends a 3-minute tolerance window)
|
||||
const webhookTimeMs = parseInt(timestamp, 10) * 1000;
|
||||
if (isNaN(webhookTimeMs) || Math.abs(Date.now() - webhookTimeMs) > 3 * 60 * 1000) {
|
||||
return res.status(400).json({ success: false, message: 'Webhook timestamp outside acceptable window' });
|
||||
}
|
||||
|
||||
// Construct the signed content
|
||||
const signedContent = `${id}.${timestamp}.${requestBody}`;
|
||||
|
||||
// Webhook secret from Site Settings → Yoco (falls back to .env)
|
||||
const secret = await getYocoWebhookSecret();
|
||||
if (!secret) {
|
||||
console.error('Yoco webhook secret is not configured (Site Settings → Yoco, or YOCO_WEBHOOK_SECRET)');
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: 'Server configuration error',
|
||||
details: { error: 'Missing webhook secret configuration' }
|
||||
};
|
||||
return res.status(500).json(responseObj);
|
||||
}
|
||||
|
||||
const secretBytes = Buffer.from(secret.split('_')[1], "base64");
|
||||
|
||||
// Calculate expected signature
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secretBytes)
|
||||
.update(signedContent)
|
||||
.digest('base64');
|
||||
|
||||
// Accept if any of the space-separated signatures match (supports key rotation)
|
||||
const signatures = rawSigHeader.split(' ').map(s => s.split(',')[1]).filter(Boolean);
|
||||
const signatureValid = signatures.some(sig => {
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(sig));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!signatureValid) {
|
||||
console.error('Invalid webhook signature');
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: 'Invalid signature',
|
||||
details: { error: 'Webhook signature verification failed' }
|
||||
};
|
||||
return res.status(403).json(responseObj);
|
||||
}
|
||||
|
||||
// Parse the webhook payload
|
||||
const webhookData = JSON.parse(requestBody);
|
||||
|
||||
// Extract checkoutId from payload or metadata
|
||||
const receivedCheckoutId = webhookData?.payload?.checkoutId || webhookData?.payload?.metadata?.checkoutId || webhookData?.payload?.metadata?.checkout_id;
|
||||
|
||||
// Optional filtering: allow only specific checkoutIds or prefixes for this endpoint
|
||||
try {
|
||||
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
|
||||
const prefixesEnv = process.env.YOCO_ALLOWED_CHECKOUT_PREFIXES || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_PREFIXES || '';
|
||||
const allowedIds = idsEnv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const allowedPrefixes = prefixesEnv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
const hasFilter = allowedIds.length > 0 || allowedPrefixes.length > 0;
|
||||
if (hasFilter) {
|
||||
const idMatches = receivedCheckoutId && (
|
||||
allowedIds.includes(receivedCheckoutId) ||
|
||||
allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref))
|
||||
);
|
||||
if (!idMatches) {
|
||||
// Store event for audit/unreconciled tracking
|
||||
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store filtered yoco event:', e?.message || e); }
|
||||
// Acknowledge but ignore processing for unrelated checkout flows
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint',
|
||||
data: {
|
||||
filtered: true,
|
||||
receivedCheckoutId,
|
||||
allowedIdsCount: allowedIds.length,
|
||||
allowedPrefixesCount: allowedPrefixes.length
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
}
|
||||
}
|
||||
} catch (filterErr) {
|
||||
// Never block webhook due to filter parsing errors; log only
|
||||
console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr);
|
||||
}
|
||||
|
||||
// Automatic filtering by database registration record, with donation allowance
|
||||
try {
|
||||
if (receivedCheckoutId) {
|
||||
const existingRegistration = await prisma.registration.findFirst({
|
||||
where: { checkoutId: receivedCheckoutId },
|
||||
select: { id: true }
|
||||
});
|
||||
if (!existingRegistration) {
|
||||
// Allow donations (no registration) to proceed based on metadata
|
||||
const meta = webhookData?.payload?.metadata || {};
|
||||
const isDonation = meta?.type === 'donation' || !!meta?.isDonation || !!meta?.eventId;
|
||||
if (!isDonation) {
|
||||
try {
|
||||
await saveYocoTransaction(webhookData);
|
||||
} catch (saveErr) {
|
||||
console.warn('Failed to save unreconciled transaction:', saveErr?.message || saveErr);
|
||||
}
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId',
|
||||
data: {
|
||||
filtered: true,
|
||||
reason: 'no_registration_for_checkoutId',
|
||||
receivedCheckoutId
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (dbFilterErr) {
|
||||
// Do not fail webhook due to DB filter issues
|
||||
console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr);
|
||||
}
|
||||
|
||||
let processedData = null;
|
||||
|
||||
// Process different event types
|
||||
switch (webhookData.type) {
|
||||
case 'payment.succeeded':
|
||||
processedData = await handlePaymentSucceeded(webhookData);
|
||||
break;
|
||||
// Add more event types as needed
|
||||
default:
|
||||
console.log(`Unhandled webhook event type: ${webhookData.type}`);
|
||||
try { await saveYocoTransaction(webhookData); } catch(e) { console.warn('Failed to store unhandled yoco event:', e?.message || e); }
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: 'Webhook received but event type not handled',
|
||||
data: {
|
||||
eventType: webhookData.type,
|
||||
webhookId: id,
|
||||
timestamp: timestamp
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
}
|
||||
|
||||
// Return detailed success response
|
||||
const responseObj = {
|
||||
success: true,
|
||||
message: `Successfully processed ${webhookData.type} webhook`,
|
||||
data: {
|
||||
eventType: webhookData.type,
|
||||
webhookId: id,
|
||||
timestamp: timestamp,
|
||||
processedData
|
||||
}
|
||||
};
|
||||
return res.status(200).json(responseObj);
|
||||
} catch (error) {
|
||||
console.error('Webhook error:', error);
|
||||
const responseObj = {
|
||||
success: false,
|
||||
message: error.message,
|
||||
details: {
|
||||
error: error.stack,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
return res.status(500).json(responseObj);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle payment.succeeded event
|
||||
const handlePaymentSucceeded = async (webhookData) => {
|
||||
try {
|
||||
const paymentData = webhookData.payload;
|
||||
|
||||
// Log the payment data
|
||||
console.log('Payment succeeded:', paymentData);
|
||||
|
||||
// Extract payment details
|
||||
const {
|
||||
id: yocoPaymentId,
|
||||
amount,
|
||||
currency,
|
||||
status,
|
||||
metadata,
|
||||
paymentMethodDetails: method,
|
||||
checkoutId
|
||||
} = paymentData;
|
||||
|
||||
// Check if payment already exists to avoid duplicates
|
||||
const existingPayment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
externalId: yocoPaymentId
|
||||
}
|
||||
});
|
||||
|
||||
if (existingPayment) {
|
||||
console.log(`Payment ${yocoPaymentId} already processed`);
|
||||
try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: existingPayment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction for existing payment:', e?.message || e); }
|
||||
return {
|
||||
status: 'already_processed',
|
||||
paymentId: existingPayment.id,
|
||||
externalId: yocoPaymentId
|
||||
};
|
||||
}
|
||||
|
||||
// Find the registration by checkoutId
|
||||
let registration = null;
|
||||
if (checkoutId) {
|
||||
registration = await prisma.registration.findFirst({
|
||||
where: {
|
||||
checkoutId: checkoutId
|
||||
},
|
||||
include: {
|
||||
event: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true
|
||||
}
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If no registration found by checkoutId, try metadata
|
||||
if (!registration && metadata && metadata.registrationId) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: {
|
||||
id: metadata.registrationId
|
||||
},
|
||||
include: {
|
||||
event: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true
|
||||
}
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve userId: registration owner → metadata → first active admin (never use a non-existent UUID)
|
||||
let resolvedUserId = registration?.userId || metadata?.userId || null;
|
||||
if (!resolvedUserId) {
|
||||
try {
|
||||
const adminUser = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
|
||||
resolvedUserId = adminUser?.id || null;
|
||||
} catch (e) { /* ignore — payment will fail below if still null */ }
|
||||
}
|
||||
|
||||
// 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
|
||||
method: method.type, // The type of payment from the webhook
|
||||
status: status === 'succeeded' ? 'completed' : status,
|
||||
externalId: yocoPaymentId,
|
||||
registrationId: registration?.id || null,
|
||||
userId: resolvedUserId,
|
||||
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
|
||||
eventId: registration?.eventId || metadata?.eventId || null,
|
||||
isDonation: !registration?.id,
|
||||
...(!registration?.id && metadata?.type === 'sponsorship' ? {
|
||||
isSponsorship: true,
|
||||
sponsorAnonymous: String(metadata.sponsorAnonymous) === 'true',
|
||||
sponsorName: String(metadata.sponsorAnonymous) === 'true' ? null : (metadata.sponsorName ? String(metadata.sponsorName).slice(0, 100) : null),
|
||||
sponsorMessage: metadata.sponsorMessage ? String(metadata.sponsorMessage).slice(0, 300) : null,
|
||||
} : {})
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
},
|
||||
recordedBy: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true
|
||||
}
|
||||
},
|
||||
registration: registration ? {
|
||||
include: {
|
||||
event: true
|
||||
}
|
||||
} : undefined,
|
||||
event: (registration?.eventId || metadata?.eventId) ? true : undefined
|
||||
}
|
||||
});
|
||||
|
||||
// If this payment is for a registration, update the registration status
|
||||
let updatedRegistration = null;
|
||||
let generatedTickets = [];
|
||||
|
||||
if (registration) {
|
||||
updatedRegistration = await updateRegistrationStatus(registration.id);
|
||||
|
||||
// If registration status is 'paid', tickets were generated
|
||||
if (updatedRegistration.status === 'paid') {
|
||||
// Find the generated tickets
|
||||
generatedTickets = await prisma.ticket.findMany({
|
||||
where: {
|
||||
registrationOption: {
|
||||
registrationId: registration.id
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
qrCode: true,
|
||||
isUsed: true,
|
||||
registrationOptionId: true,
|
||||
eventId: true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Payment ${yocoPaymentId} processed successfully`);
|
||||
|
||||
// Mark the Yoco transaction as reconciled and link the payment
|
||||
try { await saveYocoTransaction(webhookData, { reconciled: true, paymentId: payment.id }); } catch(e) { console.warn('Failed to reconcile YocoTransaction after payment create:', e?.message || e); }
|
||||
|
||||
// Send emails: payment confirmation first, then tickets (guarantees order)
|
||||
const _whPaymentId = payment.id;
|
||||
const _whUserId = registration?.userId;
|
||||
const _whRegId = registration?.id;
|
||||
const _whTicketsGenerated = generatedTickets.length > 0;
|
||||
(async () => {
|
||||
try {
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
await sendPaymentEmails(_whPaymentId);
|
||||
} catch (e) {
|
||||
console.error('Failed to send webhook payment emails:', e);
|
||||
}
|
||||
if (_whTicketsGenerated && _whUserId && _whRegId) {
|
||||
const mockReq = { user: { id: _whUserId }, body: { registrationId: _whRegId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets from webhook:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Return processed data
|
||||
return {
|
||||
status: 'success',
|
||||
payment: {
|
||||
id: payment.id,
|
||||
externalId: yocoPaymentId,
|
||||
amount: payment.amount,
|
||||
method: payment.method,
|
||||
status: payment.status,
|
||||
createdAt: payment.createdAt
|
||||
},
|
||||
registration: registration ? {
|
||||
id: registration.id,
|
||||
status: updatedRegistration ? updatedRegistration.status : registration.status,
|
||||
eventId: registration.eventId,
|
||||
eventTitle: registration.event?.title,
|
||||
userId: registration.userId,
|
||||
userName: registration.user?.name
|
||||
} : null,
|
||||
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing payment.succeeded event:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Update registration status based on payments
|
||||
const updateRegistrationStatus = async (registrationId) => {
|
||||
try {
|
||||
// Refresh early-bird pricing before computing totalDue — ensures expired/exhausted tiers
|
||||
// are accounted for so we never mark a registration paid based on stale prices.
|
||||
try { await refreshPricingForRegistration(registrationId); } catch (e) {
|
||||
console.warn('[webhook] Price refresh failed:', e?.message);
|
||||
}
|
||||
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: {
|
||||
include: {
|
||||
earlyBirdTiers: true
|
||||
}
|
||||
},
|
||||
tranches: true
|
||||
}
|
||||
},
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
throw new Error(`Registration ${registrationId} not found`);
|
||||
}
|
||||
|
||||
// Calculate total amount paid
|
||||
const totalPaid = registration.payments.reduce((sum, payment) => sum + payment.amount, 0);
|
||||
|
||||
// Calculate total amount due (uses priceSnapshot — refreshed above)
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
|
||||
// Update registration status based on payment
|
||||
let newStatus;
|
||||
if (totalPaid >= totalDue) {
|
||||
newStatus = 'paid';
|
||||
} else if (totalPaid > 0) {
|
||||
newStatus = 'partial_paid';
|
||||
} else {
|
||||
newStatus = 'pending';
|
||||
}
|
||||
|
||||
const updatedRegistration = await prisma.registration.update({
|
||||
where: { id: registrationId },
|
||||
data: {
|
||||
status: newStatus,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Generate tickets if status is "paid" (email handled by caller after payment email)
|
||||
let generatedTickets = [];
|
||||
if (newStatus === 'paid') {
|
||||
try {
|
||||
generatedTickets = await generateTicketsForRegistration(registrationId);
|
||||
} catch (error) {
|
||||
console.error('Error generating tickets:', error);
|
||||
// Don't throw the error, just log it - we still want to update the registration status
|
||||
}
|
||||
}
|
||||
|
||||
// Return the updated registration
|
||||
return {
|
||||
...updatedRegistration,
|
||||
totalPaid,
|
||||
totalDue,
|
||||
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating registration status:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const handleYocoWebhook = handleProviderWebhook('yoco');
|
||||
|
||||
const handleWhatsappWebhook = async (req, res) => {
|
||||
try {
|
||||
@@ -527,62 +18,3 @@ module.exports = {
|
||||
handleYocoWebhook,
|
||||
handleWhatsappWebhook,
|
||||
};
|
||||
|
||||
// Helper: save or update Yoco transactions for any webhook event
|
||||
async function saveYocoTransaction(webhookData, updates = {}) {
|
||||
try {
|
||||
const payload = webhookData?.payload || {};
|
||||
const externalId = payload?.id || webhookData?.id;
|
||||
if (!externalId) {
|
||||
throw new Error('Missing external id in webhook payload');
|
||||
}
|
||||
|
||||
const amount = typeof payload?.amount === 'number' ? payload.amount : null; // cents if provided
|
||||
const currency = payload?.currency || null;
|
||||
const createdDateStr = payload?.createdDate || webhookData?.createdDate;
|
||||
const createdDate = createdDateStr ? new Date(createdDateStr) : null;
|
||||
const checkoutId = payload?.checkoutId || payload?.metadata?.checkoutId || payload?.metadata?.checkout_id || null;
|
||||
const methodType = payload?.paymentMethodDetails?.type || null;
|
||||
|
||||
// Try find existing
|
||||
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
|
||||
if (existing) {
|
||||
// Update with any provided updates
|
||||
if (updates && Object.keys(updates).length > 0) {
|
||||
return await prisma.yocoTransaction.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...updates, updatedAt: new Date() }
|
||||
});
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new
|
||||
const record = await prisma.yocoTransaction.create({
|
||||
data: {
|
||||
externalId,
|
||||
amount,
|
||||
currency,
|
||||
createdDate,
|
||||
checkoutId,
|
||||
methodType,
|
||||
reconciled: false,
|
||||
raw: webhookData,
|
||||
...updates
|
||||
}
|
||||
});
|
||||
return record;
|
||||
} catch (e) {
|
||||
// Unique constraint race: fetch existing
|
||||
if (e?.code === 'P2002') {
|
||||
const payload = webhookData?.payload || {};
|
||||
const externalId = payload?.id || webhookData?.id;
|
||||
const existing = await prisma.yocoTransaction.findFirst({ where: { externalId } });
|
||||
if (existing && updates && Object.keys(updates).length > 0) {
|
||||
return await prisma.yocoTransaction.update({ where: { id: existing.id }, data: { ...updates, updatedAt: new Date() } });
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const prisma = require('../config/db');
|
||||
const { normaliseKind, filterRecipients, whatsAppNewsFooter } = require('../utils/privacy');
|
||||
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
@@ -82,9 +83,10 @@ const previewWhatsAppBroadcast = async (req, res) => {
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'whatsapp', kind });
|
||||
|
||||
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
return res.json({ kind, matched: recipients.length, skipped, recipients: recipients.slice(0, 20) });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -123,9 +125,12 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
// News unless marked as an event update; news skips people who haven't opted in to
|
||||
// WhatsApp news, replied STOP or unsubscribed.
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'whatsapp', kind });
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid recipients with phone numbers' });
|
||||
return res.status(400).json({ message: skipped ? 'Everyone selected has unsubscribed from WhatsApp news.' : 'No valid recipients with phone numbers' });
|
||||
}
|
||||
|
||||
let event = null;
|
||||
@@ -139,7 +144,7 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink };
|
||||
const finalMessage = replacePlaceholders(message, ctx);
|
||||
await sendText(rcpt.phone, finalMessage);
|
||||
await sendText(rcpt.phone, kind === 'news' ? finalMessage + whatsAppNewsFooter(rcpt) : finalMessage);
|
||||
}));
|
||||
|
||||
const sent = results.filter(r => r.status === 'fulfilled').length;
|
||||
@@ -149,7 +154,7 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ matched: recipients.length, sent });
|
||||
return res.json({ kind, matched: recipients.length, skipped, sent });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -160,13 +165,13 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
// @access Private/Supervisor or Admin
|
||||
const scheduleWhatsAppBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { scheduledAt, message, userIds, phones, eventId } = req.body || {};
|
||||
const { scheduledAt, message, userIds, phones, eventId, kind } = req.body || {};
|
||||
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
if (!message) return res.status(400).json({ message: 'message is required' });
|
||||
|
||||
const payload = { message, userIds, phones, eventId };
|
||||
const payload = { message, userIds, phones, eventId, kind };
|
||||
|
||||
const recipientSummary = await describeBroadcastRecipients({ userIds, phones });
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const getConfigHandler = async (req, res) => {
|
||||
hasToken: !!token,
|
||||
hasInstance: !!instanceId,
|
||||
configured: !!(token && instanceId),
|
||||
webhookUrl: await require('../utils/whatsapp').webhookUrl(),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.message || 'Failed to load config' });
|
||||
@@ -162,6 +163,7 @@ const handleWebhook = async (req, res) => {
|
||||
|
||||
try {
|
||||
const { event, session } = req.body || {};
|
||||
if (event === 'message') return await handleIncomingMessage(req);
|
||||
if (event !== 'session.status') return;
|
||||
|
||||
const status = session?.status;
|
||||
@@ -210,6 +212,37 @@ const handleWebhook = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* An incoming WhatsApp message (WAWP `message` event). The only thing we act on is an
|
||||
* opt-out reply — "STOP" — which stops News & announcements on WhatsApp for that number
|
||||
* (event updates still go out). Only trusted when the URL carries our webhook secret,
|
||||
* since WAWP doesn't sign webhooks.
|
||||
*/
|
||||
async function handleIncomingMessage(req) {
|
||||
const crypto = require('crypto');
|
||||
const { getWebhookSecret, phoneFromChatId, sendText } = require('../utils/whatsapp');
|
||||
const { isStopKeyword, whatsAppStop } = require('../utils/privacy');
|
||||
|
||||
const given = String(req.query?.secret || '');
|
||||
const secret = await getWebhookSecret();
|
||||
if (given.length !== secret.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(secret))) {
|
||||
console.warn('[whatsapp webhook] Ignored a message event without a valid secret');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = req.body?.payload || {};
|
||||
if (payload.fromMe || !isStopKeyword(payload.body)) return;
|
||||
const phone = phoneFromChatId(payload.from);
|
||||
if (!phone) {
|
||||
// Groups, channels, and senders WhatsApp only identifies by a "LID" (no phone number).
|
||||
console.warn('[whatsapp webhook] STOP from a sender without a phone number:', payload.from);
|
||||
return;
|
||||
}
|
||||
const result = await whatsAppStop(phone);
|
||||
if (!result) return;
|
||||
await sendText(phone, "You won't get news and announcements from us on WhatsApp any more. You'll still get messages about events you've registered for.").catch(() => {});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfigHandler,
|
||||
saveConfigHandler,
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
const prisma = require('../config/db');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
|
||||
function getYocoModel() {
|
||||
// Gracefully handle environments where the Prisma client hasn't been regenerated
|
||||
// and YocoTransaction model is not available yet.
|
||||
return prisma && prisma.yocoTransaction ? prisma.yocoTransaction : null;
|
||||
}
|
||||
|
||||
// @desc Get all Yoco transactions (optionally filter by reconciled and ignored)
|
||||
// @route GET /api/yoco-transactions
|
||||
// @access Private/Admin or Supervisor
|
||||
const getAllYocoTransactions = async (req, res) => {
|
||||
try {
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
|
||||
const { reconciled, ignored } = req.query;
|
||||
const where = {};
|
||||
if (typeof reconciled !== 'undefined') where.reconciled = String(reconciled) === 'true';
|
||||
if (typeof ignored !== 'undefined') where.ignored = String(ignored) === 'true';
|
||||
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
|
||||
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
|
||||
const items = await Yoco.findMany({
|
||||
where,
|
||||
orderBy: [{ createdDate: 'desc' }, { createdAt: 'desc' }]
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true, data: items });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Yoco transactions:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get unreconciled Yoco transactions (not reconciled and not ignored)
|
||||
// @route GET /api/yoco-transactions/unreconciled
|
||||
// @access Private/Admin or Supervisor
|
||||
const getUnreconciledYocoTransactions = async (req, res) => {
|
||||
try {
|
||||
// Basic role check if auth middleware sets req.user
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
console.warn('YocoTransaction model not available on Prisma client. Did you run migrations and `prisma generate`?');
|
||||
return res.status(200).json({ success: true, data: [], message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
|
||||
const items = await Yoco.findMany({
|
||||
where: { reconciled: false, ignored: false },
|
||||
orderBy: { createdDate: 'desc' }
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true, data: items });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unreconciled Yoco transactions:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reconcile a Yoco transaction to a Registration or Donation (creates Payment)
|
||||
// @route POST /api/yoco-transactions/:id/reconcile
|
||||
// @access Private/Admin or Supervisor
|
||||
const reconcileYocoTransaction = async (req, res) => {
|
||||
try {
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
|
||||
|
||||
const ytx = await Yoco.findUnique({ where: { id } });
|
||||
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
|
||||
if (ytx.reconciled && ytx.paymentId) {
|
||||
return res.status(409).json({ success: false, message: 'Already reconciled', data: ytx });
|
||||
}
|
||||
|
||||
// Load registration if provided
|
||||
let registration = null;
|
||||
if (registrationId) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
event: true,
|
||||
user: true,
|
||||
registrationOptions: { include: { eventOption: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
if (!registration) return res.status(404).json({ success: false, message: 'Registration not found' });
|
||||
}
|
||||
|
||||
// Determine payment fields
|
||||
const amountFloat = typeof ytx.amount === 'number' ? (ytx.amount / 100) : 0;
|
||||
if (!amountFloat || amountFloat <= 0) {
|
||||
return res.status(400).json({ success: false, message: 'Invalid amount on YocoTransaction for reconciliation' });
|
||||
}
|
||||
|
||||
const externalId = ytx.externalId || undefined;
|
||||
|
||||
// Resolve a valid userId for the Payment to satisfy FK constraints
|
||||
let resolvedUserId = null;
|
||||
if (registration?.userId) {
|
||||
resolvedUserId = registration.userId;
|
||||
} else if (bodyUserId) {
|
||||
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
|
||||
// over metadata guesswork, but still verify the user actually exists.
|
||||
try {
|
||||
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
|
||||
if (exists) resolvedUserId = String(bodyUserId);
|
||||
} catch {}
|
||||
}
|
||||
if (!resolvedUserId && ytx?.raw?.payload?.metadata?.userId) {
|
||||
const metaUserId = String(ytx.raw.payload.metadata.userId);
|
||||
try {
|
||||
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
|
||||
if (exists) resolvedUserId = metaUserId;
|
||||
} catch {}
|
||||
}
|
||||
if (!resolvedUserId && req.user?.id) {
|
||||
// Fallback to the acting supervisor/admin to avoid FK violations
|
||||
resolvedUserId = req.user.id;
|
||||
}
|
||||
if (!resolvedUserId) {
|
||||
return res.status(400).json({ success: false, message: 'Unable to resolve a valid user for this payment' });
|
||||
}
|
||||
|
||||
// Build payment data
|
||||
const paymentData = {
|
||||
amount: amountFloat,
|
||||
method: ytx.methodType || 'card',
|
||||
userId: resolvedUserId,
|
||||
recordedById: req.user?.id || null, // staff who performed the reconciliation
|
||||
registrationId: registration?.id || null,
|
||||
eventId: registration?.eventId || eventId || null,
|
||||
isDonation: !registration?.id,
|
||||
externalId: externalId,
|
||||
status: 'completed'
|
||||
};
|
||||
|
||||
// Ensure donation has an eventId
|
||||
if (!registration && !paymentData.eventId) {
|
||||
return res.status(400).json({ success: false, message: 'eventId is required when reconciling as donation' });
|
||||
}
|
||||
|
||||
// Create payment
|
||||
// Preserve original Yoco creation time to correctly evaluate early-bird pricing
|
||||
const createdAt = ytx.createdDate || ytx.createdAt || new Date();
|
||||
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
|
||||
|
||||
// Optionally update registration status when applicable and generate tickets if paid.
|
||||
// Ticket generation stays synchronous so `generatedTickets` can be included in the response;
|
||||
// emailing/WhatsApp-ing the tickets and payment confirmation are backgrounded below since
|
||||
// they involve slow PDF rendering + SMTP/WAWP round trips that shouldn't block this request.
|
||||
let generatedTickets = [];
|
||||
if (registration) {
|
||||
try {
|
||||
const updatedReg = await updateRegistrationStatus(registration.id);
|
||||
if (updatedReg && updatedReg.status === 'paid') {
|
||||
try {
|
||||
generatedTickets = await generateTicketsForRegistration(registration.id);
|
||||
} catch (genErr) {
|
||||
console.error('Error generating tickets after Yoco reconciliation:', genErr);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// log but do not fail reconciliation
|
||||
console.warn('Failed to update registration after reconciliation:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
const _rxPaymentId = payment.id;
|
||||
const _rxUserId = registration?.userId;
|
||||
const _rxRegId = registration?.id;
|
||||
const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0;
|
||||
(async () => {
|
||||
try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); }
|
||||
if (_rxShouldEmailTickets && _rxUserId) {
|
||||
const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after Yoco reconciliation:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Update yoco transaction as reconciled
|
||||
const updatedTx = await Yoco.update({
|
||||
where: { id: ytx.id },
|
||||
data: { reconciled: true, paymentId: payment.id }
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment, generatedTickets: (generatedTickets && generatedTickets.length) ? generatedTickets : undefined } });
|
||||
} catch (error) {
|
||||
console.error('Failed to reconcile Yoco transaction:', error);
|
||||
// Handle unique externalId conflicts (if a Payment with same externalId already exists)
|
||||
if (error?.code === 'P2002') {
|
||||
try {
|
||||
const existing = await prisma.payment.findFirst({ where: { externalId: (await getYocoModel()?.findUnique({ where: { id: req.params.id } }))?.externalId } });
|
||||
if (existing) {
|
||||
const updatedTx = await getYocoModel()?.update({ where: { id: req.params.id }, data: { reconciled: true, paymentId: existing.id } });
|
||||
if (updatedTx) {
|
||||
return res.status(200).json({ success: true, data: { yocoTransaction: updatedTx, payment: existing }, message: 'Linked to existing payment' });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// Minimal registration status update mirroring webhook logic
|
||||
async function updateRegistrationStatus(registrationId) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
if (!registration) return null;
|
||||
const totalPaid = (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = require('../utils/pricing').computeRegistrationTotalDue(registration, new Date());
|
||||
let newStatus = 'pending';
|
||||
if (totalPaid >= totalDue) newStatus = 'paid';
|
||||
else if (totalPaid > 0) newStatus = 'partial_paid';
|
||||
return prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
|
||||
}
|
||||
|
||||
// @desc Ignore a Yoco transaction (mark as ignored and reconciled without creating a payment)
|
||||
// @route POST /api/yoco-transactions/:id/ignore
|
||||
// @access Private/Admin or Supervisor
|
||||
const ignoreYocoTransaction = async (req, res) => {
|
||||
try {
|
||||
if (!req.user || !['admin', 'supervisor'].includes(req.user.role)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||
}
|
||||
const Yoco = getYocoModel();
|
||||
if (!Yoco) {
|
||||
return res.status(400).json({ success: false, message: 'YocoTransaction model not available. Apply DB migration and regenerate Prisma client.' });
|
||||
}
|
||||
const { id } = req.params;
|
||||
const ytx = await Yoco.findUnique({ where: { id } });
|
||||
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
|
||||
|
||||
if (ytx.reconciled && ytx.ignored) {
|
||||
return res.status(200).json({ success: true, data: ytx, message: 'Already ignored' });
|
||||
}
|
||||
|
||||
const updated = await Yoco.update({
|
||||
where: { id },
|
||||
data: { ignored: true, reconciled: true }
|
||||
});
|
||||
return res.status(200).json({ success: true, data: updated });
|
||||
} catch (error) {
|
||||
console.error('Failed to ignore Yoco transaction:', error);
|
||||
return res.status(500).json({ success: false, message: 'Server error', details: error?.message || String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllYocoTransactions,
|
||||
getUnreconciledYocoTransactions,
|
||||
reconcileYocoTransaction,
|
||||
ignoreYocoTransaction
|
||||
};
|
||||
+133
-219
@@ -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;
|
||||
@@ -134,7 +131,7 @@ const webhookRoutes = require('./routes/webhookRoutes');
|
||||
const uploadRoutes = require('./routes/uploadRoutes');
|
||||
const reportRoutes = require('./routes/reportRoutes');
|
||||
const formRoutes = require('./routes/formRoutes');
|
||||
const yocoTransactionRoutes = require('./routes/yocoTransactionRoutes');
|
||||
const providerTransactionRoutes = require('./routes/providerTransactionRoutes');
|
||||
const automationRoutes = require('./routes/automationRoutes');
|
||||
const broadcastRoutes = require('./routes/broadcastRoutes');
|
||||
const whatsappBroadcastRoutes = require('./routes/whatsappBroadcastRoutes');
|
||||
@@ -150,6 +147,8 @@ const statsRoutes = require('./routes/statsRoutes');
|
||||
const adminRoutes = require('./routes/adminRoutes');
|
||||
const backupRoutes = require('./routes/backupRoutes');
|
||||
const yocoRoutes = require('./routes/yocoRoutes');
|
||||
const payfastRoutes = require('./routes/payfastRoutes');
|
||||
const privacyRoutes = require('./routes/privacyRoutes');
|
||||
|
||||
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
|
||||
app.use('/api/webhooks', webhookRoutes);
|
||||
@@ -166,7 +165,8 @@ app.use('/api/tickets', ticketRoutes);
|
||||
app.use('/api/uploads', uploadRoutes);
|
||||
app.use('/api/reports', reportRoutes);
|
||||
app.use('/api/forms', formRoutes);
|
||||
app.use('/api/yoco-transactions', yocoTransactionRoutes);
|
||||
app.use('/api/provider-transactions', providerTransactionRoutes);
|
||||
app.use('/api/yoco-transactions', providerTransactionRoutes); // older name
|
||||
app.use('/api/automations', automationRoutes);
|
||||
app.use('/api/broadcasts', broadcastRoutes);
|
||||
app.use('/api/whatsapp-broadcasts', whatsappBroadcastRoutes);
|
||||
@@ -174,6 +174,7 @@ app.use('/api/scheduled-emails', scheduledEmailRoutes);
|
||||
app.use('/api/sections', sectionRoutes);
|
||||
app.use('/api/banner', bannerRoutes);
|
||||
app.use('/api/whatsapp', whatsappRoutes);
|
||||
app.use('/api/privacy', privacyRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/setup', setupRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
@@ -181,15 +182,18 @@ 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);
|
||||
app.use('/api/payfast', payfastRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
const { getSettingSync, warmCache } = require('./utils/settingsCache');
|
||||
@@ -509,7 +513,7 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'POST', path:'/api/users/:id/revoke-sessions', auth:'admin', desc:'Revoke all sessions for another user',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
responses:[{ status:200, desc:'Success', body:{ message:'Sessions revoked for user.' }}]},
|
||||
{ method:'POST', path:'/api/users/:id/anonymize', auth:'admin', desc:'Erase personal data for a user — sets name to "Deleted User", email to deleted-{id}@deleted.local, clears phone number, deactivates account, revokes all sessions. Irreversible.',
|
||||
{ method:'POST', path:'/api/users/:id/anonymize', auth:'admin', desc:'Erase personal data for a user (POPIA) — the account, the people they registered, form answers, consent details, sponsor names, login history and payer details in provider payloads; payments, registrations and tickets stay as "Deleted User". Refused (409) while they have a registration for an upcoming event. Completes an open deletion request. Irreversible; audited as user_anonymised.',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
responses:[
|
||||
{ status:200, desc:'Anonymised', body:{ message:'User data deleted' }},
|
||||
@@ -548,7 +552,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 }}]},
|
||||
@@ -662,6 +666,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' },
|
||||
@@ -741,6 +747,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.' }}]},
|
||||
@@ -757,7 +777,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',
|
||||
@@ -769,15 +789,34 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'PUT', path:'/api/coupons/:id', auth:'supervisor+', desc:'Edit a code or turn it off ({ active:false }). Discounts already given keep their amount; a used code can\'t be renamed.' },
|
||||
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
|
||||
]},
|
||||
{ title: 'Bundle deals', base: '/api/bundles', endpoints: [
|
||||
{ method:'POST', path:'/api/bundles/quote', auth:'public', desc:'The bundle deals a basket gets, applied automatically as many times as they fit and only when cheaper. Staff can pass forUserId (or null for a walk-in).',
|
||||
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }] }},
|
||||
responses:[{ status:200, desc:'Success', body:{ subtotal:550, bundleDiscount:50, bundles:[{ bundleId:'bnd-uuid-...', name:'Family deal', count:1, discount:50 }], total:500 }}]},
|
||||
{ method:'GET', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'An event\'s bundles with uses and total discount given' },
|
||||
{ method:'POST', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'Create a bundle. kind: exact (items with quantities, price for the set) | mix (any setSize of the items for price) | free (any setSize, cheapest freeCount free) | group (setSize or more of the items: groupPricing per_ticket price each, or percent off). items: [{ eventOptionId, variantId?, quantity }] (variantId left out = any variant; quantity only for exact).',
|
||||
request:{ body:{ name:'Family deal', price:500, items:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }], validUntil:null }}},
|
||||
{ method:'PUT', path:'/api/bundles/:id', auth:'supervisor+', desc:'Edit a bundle or switch it off ({ isActive:false }). Registrations keep a bundle they already have.' },
|
||||
{ method:'DELETE', path:'/api/bundles/:id', auth:'supervisor+', desc:'Delete a bundle; one that has been used is switched off instead' },
|
||||
]},
|
||||
{ title: 'Payments', base: '/api/payments', endpoints: [
|
||||
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
|
||||
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
|
||||
{ method:'POST', path:'/api/payments/yoco-checkout/send', auth:'supervisor+', desc:'Send a generated Yoco payment link to the registrant by email or WhatsApp', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
|
||||
{ method:'POST', path:'/api/payments/checkout/send', auth:'supervisor+', desc:'Send a generated payment link to the registrant by email or WhatsApp (also at /api/payments/yoco-checkout/send)', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
|
||||
{ method:'GET', path:'/api/payments/providers', auth:'—', desc:'Payment providers people can pay with now (enabled in Site settings → Payments and set up), in display order, plus the online minimum (R15)',
|
||||
responses:[{ status:200, desc:'Success', body:{ providers:[{ id:'yoco', label:'Yoco' }], minimumAmount:15 }}]},
|
||||
{ method:'GET', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'The payer\'s page for a checkout: amount, description, providers to choose from, or the PayFast form to post' },
|
||||
{ method:'POST', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'Start the checkout with the provider the payer picked: { provider } → { redirectUrl } or { form }' },
|
||||
{ method:'GET', path:'/api/payments/checkouts/open', auth:'supervisor+', desc:'Online checkouts from the last 14 days with no payment yet, and whether their provider can be asked (canCheck)' },
|
||||
{ method:'POST', path:'/api/payments/checkouts/check', auth:'supervisor+', desc:'Ask providers whether checkouts were paid ({ ids? }, default all open) and record what they received. PayFast: transaction history; Yoco: cant_check' },
|
||||
{ method:'GET', path:'/api/payments/providers/settings', auth:'admin', desc:'Every provider with enabled, configured and capabilities ({ refund, getTransaction, listTransactions }), in display order' },
|
||||
{ method:'PUT', path:'/api/payments/providers/settings', auth:'admin', desc:'Turn providers on/off and set their order (the list order). Audit logged.',
|
||||
request:{ body:{ providers:[{ id:'yoco', enabled:true }] }}},
|
||||
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
|
||||
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
|
||||
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
|
||||
{ method:'POST', path:'/api/payments/checkout', auth:'user+', desc:'Start an online checkout (also at /api/payments/yoco-checkout) with body.provider, or the first enabled provider. At least R15 (all providers). Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
|
||||
request:{ body:{ registrationId:'reg-uuid-...', amount:450, provider:'yoco', successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }},
|
||||
{ status:200, desc:'Checkout created — send the payer to redirectUrl', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', provider:'yoco', amount:450 }},
|
||||
{ status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/payments', auth:'supervisor+', desc:'Record a manual payment (cash, EFT, etc.)',
|
||||
@@ -847,6 +886,56 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Success', body:[{ eventId:'ev-uuid-...', title:'Camp 2025', totalTickets:50, scanned:38, remaining:12 }]}]},
|
||||
]},
|
||||
|
||||
{ title: 'Privacy (POPIA)', base: '/api/privacy', endpoints: [
|
||||
{ method:'GET', path:'/api/privacy/me', auth:'user', desc:'My news opt-in, its history, and my privacy requests',
|
||||
responses:[{ status:200, desc:'Success', body:{ news:{ email:true, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z', canWhatsApp:false, notificationPreference:'email', history:[{ channel:'email', granted:true, source:'signup', createdAt:'2026-10-23T08:00:00.000Z' }], requests:[] }}]},
|
||||
{ method:'PUT', path:'/api/privacy/me/news', auth:'user', desc:'Change the News & announcements opt-in per channel (source "prompt" for the one-time question after login)',
|
||||
request:{ body:{ email:false, whatsapp:false } },
|
||||
responses:[{ status:200, desc:'Saved', body:{ news:{ email:false, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z' }}]},
|
||||
{ method:'GET', path:'/api/privacy/me/export', auth:'user', desc:'Download my data — JSON, or ?format=pdf for a readable PDF. Blocked while impersonating',
|
||||
responses:[{ status:200, desc:'File download' }]},
|
||||
{ method:'POST', path:'/api/privacy/me/requests', auth:'user', desc:'Correction request ({ type:"correction", details }) or delete my account ({ type:"deletion", password }) — deletion closes the account at once',
|
||||
request:{ body:{ type:'correction', details:'My son\'s date of birth on the Camp registration is wrong — it should be 3 May 2012.' } },
|
||||
responses:[{ status:201, desc:'Created', body:{ request:{ id:'req-uuid-...', type:'correction', status:'open', dueAt:'2026-11-22T08:00:00.000Z' }, message:'Your request was sent.' }}, { status:409, desc:'Already open', body:{ message:'You already have an open correction request.' }}]},
|
||||
{ method:'GET', path:'/api/privacy/unsubscribe/:token', auth:'public', desc:'What an unsubscribe link (signed token) is for',
|
||||
responses:[{ status:200, desc:'Success', body:{ kind:'account', email:'jo***@example.com', hasPhone:true, news:{ email:true, whatsapp:true } }}]},
|
||||
{ method:'POST', path:'/api/privacy/unsubscribe/:token', auth:'public', desc:'Unsubscribe from news ({ channels?: ["email","whatsapp"] }, default both)',
|
||||
responses:[{ status:200, desc:'Done', body:{ message:'You have been unsubscribed.' }}]},
|
||||
{ method:'POST', path:'/api/privacy/unsubscribe/:token/one-click', auth:'public', desc:'RFC 8058 one-click unsubscribe from the List-Unsubscribe email header (email news off)',
|
||||
responses:[{ status:200, desc:'Done' }]},
|
||||
{ method:'GET', path:'/api/privacy/requests', auth:'admin', desc:'Data requests inbox (?status=open|closed|all), with due dates and, for deletions, the registrations still blocking it',
|
||||
responses:[{ status:200, desc:'Success', body:{ requests:[{ id:'req-uuid-...', type:'deletion', status:'open', name:'Jane Doe', email:'jane@example.com', dueAt:'2026-11-22T08:00:00.000Z', overdue:false, blockers:[] }], openCount:1 }}]},
|
||||
{ method:'POST', path:'/api/privacy/requests/:id/complete', auth:'admin', desc:'Complete a request (emailed to the person). Deletion anonymises the account; 409 while it has a registration for an upcoming event',
|
||||
request:{ body:{ response:'Corrected the date of birth on the Camp 2026 registration.' } },
|
||||
responses:[{ status:200, desc:'Completed', body:{ request:{ status:'completed' }, anonymised:null }}]},
|
||||
{ method:'POST', path:'/api/privacy/requests/:id/reject', auth:'admin', desc:'Reject a request, or mark it withdrawn (cancelled:true); a deletion request reopens the account',
|
||||
request:{ body:{ response:'We could not find a registration with that name.', cancelled:false } },
|
||||
responses:[{ status:200, desc:'Closed', body:{ request:{ status:'rejected' } }}]},
|
||||
{ method:'GET', path:'/api/privacy/notice', auth:'public', desc:'The privacy notice (current, or ?version=N) with org details and the operator list filled in. Version 0 = built-in, until one is published',
|
||||
responses:[{ status:200, desc:'Success', body:{ version:2, builtIn:false, current:true, effectiveDate:'23 October 2026', changeSummary:'We added WhatsApp news.', sections:[{ id:'introduction', title:'1. Introduction', body:'Hope Church values your privacy…' }], versions:[{ version:2, publishedAt:'2026-10-23T10:00:00.000Z', requiresAcceptance:true }] }}]},
|
||||
{ method:'POST', path:'/api/privacy/me/notice/accept', auth:'user', desc:'Accept the current privacy notice version',
|
||||
responses:[{ status:200, desc:'Accepted', body:{ version:2 }}]},
|
||||
{ method:'GET', path:'/api/privacy/notice/draft', auth:'admin', desc:'Notice editor: draft (or current) sections, rendered preview, published versions with acceptance counts, built-in text',
|
||||
responses:[{ status:200, desc:'Success', body:{ sections:[], rendered:[], isDraft:false, savedAt:null, versions:[{ version:1, acceptances:12 }] }}]},
|
||||
{ method:'PUT', path:'/api/privacy/notice/draft', auth:'admin', desc:'Save the draft ({ sections: [{ id?, title, body }] })',
|
||||
request:{ body:{ sections:[{ title:'1. Introduction', body:'{org} values your privacy.' }] } },
|
||||
responses:[{ status:200, desc:'Saved', body:{ isDraft:true }}]},
|
||||
{ method:'DELETE', path:'/api/privacy/notice/draft', auth:'admin', desc:'Discard the draft',
|
||||
responses:[{ status:200, desc:'Discarded', body:{ message:'Draft discarded.' }}]},
|
||||
{ method:'POST', path:'/api/privacy/notice/publish', auth:'admin', desc:'Publish the next version; requiresAcceptance asks everyone to accept it after their next login (needs changeSummary)',
|
||||
request:{ body:{ sections:[{ title:'1. Introduction', body:'{org} values your privacy.' }], changeSummary:'We added WhatsApp news.', requiresAcceptance:true } },
|
||||
responses:[{ status:201, desc:'Published', body:{ version:2, requiresAcceptance:true }}]},
|
||||
{ method:'GET', path:'/api/privacy/operators', auth:'admin', desc:'The operator register (third parties that process personal information)',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'op-uuid-...', name:'PayFast', purpose:'Online payments', dataShared:'Name, email address, payment amount and reference', location:'South Africa', website:'https://payfast.io', active:true }] }]},
|
||||
{ method:'POST', path:'/api/privacy/operators', auth:'admin', desc:'Add an operator (PUT/DELETE /api/privacy/operators/:id to change or remove)',
|
||||
request:{ body:{ name:'Mailgun', purpose:'Sending emails', dataShared:'Name, email address, message content', location:'EU' } },
|
||||
responses:[{ status:201, desc:'Created' }]},
|
||||
{ method:'GET', path:'/api/privacy/consent-log', auth:'admin', desc:'Consent records: ?type=news|notice|parental, ?q= name/email, ?page=',
|
||||
responses:[{ status:200, desc:'Success', body:{ total:1, pageSize:50, rows:[{ at:'2026-10-23T08:00:00.000Z', name:'Jane Doe', email:'jane@example.com', channel:'email', granted:false, source:'unsubscribe_link' }] }}]},
|
||||
{ method:'GET', path:'/api/privacy/access-log', auth:'admin', desc:'Bulk exports/views of personal information: ?action=, ?actorId=, ?from=, ?to=, ?page=',
|
||||
responses:[{ status:200, desc:'Success', body:{ total:1, pageSize:50, rows:[{ at:'2026-10-23T08:00:00.000Z', actor:'Sam Supervisor', action:'report_pdf', what:'Camp 2026 attendees', records:48 }] }}]},
|
||||
]},
|
||||
|
||||
{ title: 'Broadcasts (Email)', base: '/api/broadcasts', endpoints: [
|
||||
{ method:'POST', path:'/api/broadcasts/preview', auth:'supervisor+', desc:'Dry-run a broadcast — returns resolved recipient list without sending',
|
||||
request:{ body:{ userIds:['user-uuid-1','user-uuid-2'], emails:'extra@example.com\nJohn <john@example.com>', eventId:'ev-uuid-...' }},
|
||||
@@ -987,19 +1076,20 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'resp-uuid-...', registrationId:'reg-uuid-...', createdAt:'2025-06-01T10:05:00.000Z', answers:[{ field:{ label:'Dietary requirements' }, value:'Vegetarian' }] }], total:1 }}]},
|
||||
]},
|
||||
|
||||
{ title: 'Yoco Transactions', base: '/api/yoco-transactions', endpoints: [
|
||||
{ method:'GET', path:'/api/yoco-transactions', auth:'supervisor+', desc:'All Yoco webhook transactions (raw events received from Yoco)',
|
||||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 50)', reconciled:'true|false' },
|
||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_abc', amount:45000, currency:'ZAR', reconciled:true, paymentId:'pay-uuid-...', createdDate:'2025-06-01T11:00:00.000Z' }], total:1 }}]},
|
||||
{ method:'GET', path:'/api/yoco-transactions/unreconciled', auth:'supervisor+', desc:'Transactions not yet matched to a payment record',
|
||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'ytx-uuid-...', externalId:'pi_yoco_xyz', amount:45000, currency:'ZAR', reconciled:false }], total:1 }}]},
|
||||
{ method:'POST', path:'/api/yoco-transactions/:id/reconcile', auth:'supervisor+', desc:'Manually link a Yoco transaction to an existing payment',
|
||||
pathParams:{ ':id':'YocoTransaction UUID' },
|
||||
request:{ body:{ paymentId:'pay-uuid-...' }},
|
||||
responses:[{ status:200, desc:'Reconciled', body:{ id:'ytx-uuid-...', reconciled:true, paymentId:'pay-uuid-...' }}]},
|
||||
{ method:'POST', path:'/api/yoco-transactions/:id/ignore', auth:'supervisor+', desc:'Mark a transaction as intentionally ignored',
|
||||
pathParams:{ ':id':'YocoTransaction UUID' },
|
||||
responses:[{ status:200, desc:'Ignored', body:{ id:'ytx-uuid-...', ignored:true }}]},
|
||||
{ title: 'Provider Transactions', base: '/api/provider-transactions', endpoints: [
|
||||
{ method:'GET', path:'/api/provider-transactions', auth:'supervisor+', desc:'Transactions payment providers sent by webhook (also at /api/yoco-transactions)',
|
||||
queryParams:{ provider:'yoco', reconciled:'true|false', ignored:'true|false' },
|
||||
responses:[{ status:200, desc:'Success', body:{ success:true, data:[{ id:'ptx-uuid-...', provider:'yoco', providerLabel:'Yoco', externalId:'pi_yoco_abc', amount:45000, currency:'ZAR', reconciled:true, paymentId:'pay-uuid-...', createdDate:'2025-06-01T11:00:00.000Z' }] }}]},
|
||||
{ method:'GET', path:'/api/provider-transactions/unreconciled', auth:'supervisor+', desc:'Transactions not yet matched to a payment record',
|
||||
queryParams:{ provider:'yoco' },
|
||||
responses:[{ status:200, desc:'Success', body:{ success:true, data:[{ id:'ptx-uuid-...', provider:'yoco', providerLabel:'Yoco', externalId:'pi_yoco_xyz', amount:45000, currency:'ZAR', reconciled:false }] }}]},
|
||||
{ method:'POST', path:'/api/provider-transactions/:id/reconcile', auth:'supervisor+', desc:'Record the transaction as a payment on a registration, or as a donation to an event',
|
||||
pathParams:{ ':id':'ProviderTransaction UUID' },
|
||||
request:{ body:{ registrationId:'reg-uuid-...', eventId:'(donation) event-uuid-...', userId:'(donation, optional) user-uuid-...' }},
|
||||
responses:[{ status:200, desc:'Reconciled', body:{ success:true, data:{ transaction:{ id:'ptx-uuid-...', reconciled:true, paymentId:'pay-uuid-...' }, payment:{ id:'pay-uuid-...', provider:'yoco' } } }}]},
|
||||
{ method:'POST', path:'/api/provider-transactions/:id/ignore', auth:'supervisor+', desc:'Mark a transaction as intentionally ignored',
|
||||
pathParams:{ ':id':'ProviderTransaction UUID' },
|
||||
responses:[{ status:200, desc:'Ignored', body:{ success:true, data:{ id:'ptx-uuid-...', ignored:true } }}]},
|
||||
]},
|
||||
|
||||
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
|
||||
@@ -1039,6 +1129,7 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
|
||||
]},
|
||||
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
|
||||
{ method:'POST', path:'/api/webhooks/payfast', auth:'PayFast ITN', desc:'PayFast payment notification (form-encoded). Checked as PayFast requires: signature, PayFast host, VALID from /eng/query/validate, and amount_gross equal to the checkout (otherwise held for review). COMPLETE records the payment.' },
|
||||
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
|
||||
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
|
||||
request:{ body:{ id:'evt_yoco_abc', type:'payment.succeeded', payload:{ metadata:{ checkoutId:'checkout-id-...' }, amount:45000, currency:'ZAR' }}},
|
||||
@@ -1047,6 +1138,11 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Acknowledged', body:{ received:true }}]},
|
||||
]},
|
||||
|
||||
{ title: 'PayFast', base: '/api/payfast', endpoints: [
|
||||
{ method:'GET', path:'/api/payfast/config', auth:'admin', desc:'PayFast settings (passphrase masked), mode and the ITN notify URL (with a warning when PayFast can\'t reach it).' },
|
||||
{ method:'PUT', path:'/api/payfast/config', auth:'admin', desc:'Save merchantId, merchantKey, passphrase (encrypted) and/or mode (sandbox|live). Audit logged.' },
|
||||
{ method:'POST', path:'/api/payfast/test', auth:'admin', desc:'Check the saved merchant ID and passphrase with PayFast (signed API ping).' },
|
||||
]},
|
||||
{ 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 }}]},
|
||||
@@ -1422,199 +1518,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 {
|
||||
@@ -1676,6 +1582,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,275 @@
|
||||
/**
|
||||
* 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: 'payment-status', label: 'Payments: check providers for notifications that never arrived', cron: '*/15 * * * *', env: 'PAYMENT_STATUS_JOB_ENABLED',
|
||||
run: () => require('../payments/status').runPaymentStatusJob(),
|
||||
},
|
||||
{
|
||||
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: 'privacy', label: 'Privacy requests: remind admins of requests due soon', cron: '0 8 * * *', env: 'PRIVACY_JOBS_ENABLED',
|
||||
run: () => require('../utils/privacy').remindDueRequests(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 };
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Online checkouts for registrations (the balance, part of it, or the balance plus a
|
||||
* donation). Used by the payment pages, payment links in emails and invoices, and the
|
||||
* self-service kiosk. The provider is the one asked for, the only available one, or the
|
||||
* payer's choice on /pay/<id> (./index.js); the registration remembers the checkout so the
|
||||
* webhook can find it.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { computeRegistrationTotalDue } = require('../utils/pricing');
|
||||
const payments = require('./index');
|
||||
|
||||
const CHECKOUT_INCLUDE = {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
||||
payments: 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,
|
||||
};
|
||||
|
||||
/**
|
||||
* The checkout's line items for a registration: one per line (with its tranches' prices),
|
||||
* then bundle savings and the coupon discount as deductions. Callers add payment lines.
|
||||
* @returns {{ name: string, quantity: number, unitCents: number }[]}
|
||||
*/
|
||||
function registrationLineItems(registration) {
|
||||
const { buildLineItems, discountLines } = require('../utils/pdfDocs');
|
||||
const items = buildLineItems(registration.registrationOptions).map(row => ({
|
||||
name: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
|
||||
quantity: row.quantity,
|
||||
unitCents: Math.round(row.unitPrice * 100),
|
||||
}));
|
||||
for (const line of discountLines(registration)) items.push({ name: line.label, quantity: 1, unitCents: -Math.round(line.amount * 100) });
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a checkout for a registration. Doesn't check who's asking — callers do.
|
||||
* @param {object} opts
|
||||
* @param {number} [opts.amount] - pay only this much of the balance (at least the online minimum)
|
||||
* @param {number} [opts.donationAmount] - added on top as its own line; the webhook records it
|
||||
* as a separate donation (it travels in the checkout metadata)
|
||||
* @param {string} [opts.provider] - payment provider id (default: first available)
|
||||
* @returns {Promise<{ checkoutId, redirectUrl, provider, amount, registration }>}
|
||||
*/
|
||||
async function createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount = 0, amount = null, provider = null } = {}) {
|
||||
const registration = await prisma.registration.findUnique({ where: { id: registrationId }, include: CHECKOUT_INCLUDE });
|
||||
if (!registration) throw payments.paymentError('Registration not found', 404);
|
||||
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const remaining = totalDue - totalPaid;
|
||||
if (remaining <= 0) throw payments.paymentError('This registration is already paid in full');
|
||||
|
||||
let charge = remaining;
|
||||
if (amount !== null && amount !== undefined) {
|
||||
const requested = parseFloat(amount);
|
||||
if (!(requested > 0)) throw payments.paymentError('Amount must be greater than 0');
|
||||
charge = Math.min(requested, remaining);
|
||||
}
|
||||
|
||||
const donationCents = Math.round((Number(donationAmount) || 0) * 100);
|
||||
const chargeCents = Math.round(charge * 100);
|
||||
const lineItems = registrationLineItems(registration);
|
||||
if (totalPaid > 0) lineItems.push({ name: 'Previous Payments', quantity: 1, unitCents: -Math.round(totalPaid * 100) });
|
||||
// Paying part of the balance: the rest is deferred to a later payment
|
||||
if (charge < remaining) lineItems.push({ name: 'Partial Payment', quantity: 1, unitCents: -Math.round((remaining - charge) * 100) });
|
||||
if (donationCents > 0) lineItems.push({ name: 'Donation', quantity: 1, unitCents: donationCents });
|
||||
|
||||
const amountCents = chargeCents + donationCents;
|
||||
const checkout = await payments.createCheckout(provider, {
|
||||
amountCents,
|
||||
metadata: {
|
||||
registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment',
|
||||
...(donationCents > 0 ? { donationAmount: (donationCents / 100).toFixed(2) } : {}),
|
||||
},
|
||||
lineItems,
|
||||
successUrl, cancelUrl, failureUrl,
|
||||
payer: { name: registration.user?.name || '', email: registration.user?.email || '' },
|
||||
description: registration.event.title,
|
||||
idempotencyKey: `reg_${registrationId}_${amountCents}_${Date.now()}`,
|
||||
});
|
||||
|
||||
await prisma.registration.update({ where: { id: registrationId }, data: { checkoutId: checkout.checkoutId, updatedAt: new Date() } });
|
||||
|
||||
return {
|
||||
checkoutId: checkout.checkoutId,
|
||||
redirectUrl: checkout.redirectUrl,
|
||||
provider: checkout.provider,
|
||||
amount: amount !== null && amount !== undefined ? charge : amountCents / 100,
|
||||
registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createRegistrationCheckout, registrationLineItems };
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Online payment providers.
|
||||
*
|
||||
* Every provider implements the same interface (see providers/yoco.js, providers/payfast.js):
|
||||
* id, label
|
||||
* capabilities { refund, getTransaction, listTransactions } — what its API supports
|
||||
* isConfigured() → Promise<boolean> credentials are set
|
||||
* createCheckout(input) → Promise<{ externalId?, redirectUrl? } | { form: { action, fields } }>
|
||||
* input: { checkoutId, amountCents, currency, metadata, lineItems,
|
||||
* successUrl, cancelUrl, failureUrl, payer?, description,
|
||||
* idempotencyKey }; lineItems: [{ name, quantity, unitCents }]
|
||||
* (negative unitCents = a deduction). A provider that takes the
|
||||
* payer with a signed form (PayFast) returns { form }; the payer's
|
||||
* browser posts it from /pay/<checkoutId>.
|
||||
* verifyWebhook(req) → Promise<{ ok: true } | { ok: false, status, message, details? }>
|
||||
* parseWebhook(body) → { type: 'payment.succeeded' | <other>, transaction }
|
||||
* body: the request body as the provider sent it (JSON or form)
|
||||
* transaction: { externalId, amountCents, currency, createdDate,
|
||||
* checkoutId, methodType, metadata, status }
|
||||
* readWebhookBody(raw) → (optional) turns req.rawBody into the body; default JSON.parse
|
||||
* getTransaction / refund / listTransactions — only when the capability is true
|
||||
*
|
||||
* Every checkout is recorded as a ProviderCheckout (amount, what it's for, return URLs), so
|
||||
* the payment that comes back can be matched and checked, and so the payer can be sent to
|
||||
* /pay/<id>: there they choose a provider when several are enabled, or their browser posts a
|
||||
* provider's form. Admins switch providers on and off and set their order in Site settings →
|
||||
* Payments (AppSetting `payment_providers`). Provider fees are never deducted or added:
|
||||
* payments are recorded at the full amount and fees are handled at cashup.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { getSetting } = require('../utils/settingsCache');
|
||||
|
||||
const PROVIDERS = [require('./providers/yoco'), require('./providers/payfast')];
|
||||
const BY_ID = new Map(PROVIDERS.map(p => [p.id, p]));
|
||||
|
||||
// One minimum for every provider: online payments must be at least R15.
|
||||
const MIN_ONLINE_AMOUNT = 15;
|
||||
const SETTINGS_KEY = 'payment_providers';
|
||||
|
||||
function paymentError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** A provider by id, or null. */
|
||||
function getProvider(id) {
|
||||
return BY_ID.get(String(id || '')) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled flags and order from the stored setting. Pure. Unknown ids are dropped; providers
|
||||
* not in the setting yet come last — Yoco enabled (it was the only provider before), others off.
|
||||
* @param {string} raw - JSON: { order: string[], enabled: { [id]: boolean } }
|
||||
* @param {string[]} [ids] - known provider ids, in default order
|
||||
* @returns {{ id: string, enabled: boolean }[]}
|
||||
*/
|
||||
function parseProviderSettings(raw, ids = PROVIDERS.map(p => p.id)) {
|
||||
let stored = {};
|
||||
try { stored = raw ? JSON.parse(raw) : {}; } catch { stored = {}; }
|
||||
const order = Array.isArray(stored.order) ? stored.order.filter(id => ids.includes(id)) : [];
|
||||
const all = [...new Set([...order, ...ids])];
|
||||
const enabled = stored.enabled && typeof stored.enabled === 'object' ? stored.enabled : {};
|
||||
return all.map(id => ({ id, enabled: typeof enabled[id] === 'boolean' ? enabled[id] : id === 'yoco' }));
|
||||
}
|
||||
|
||||
/** Every provider with its settings and whether it's configured, in display order. */
|
||||
async function providerSettings() {
|
||||
const list = parseProviderSettings(await getSetting(SETTINGS_KEY));
|
||||
return Promise.all(list.map(async ({ id, enabled }) => {
|
||||
const p = getProvider(id);
|
||||
return { id, label: p.label, enabled, configured: await p.isConfigured(), capabilities: p.capabilities };
|
||||
}));
|
||||
}
|
||||
|
||||
/** Providers people can pay with now (enabled and configured), in display order. */
|
||||
async function availableProviders() {
|
||||
return (await providerSettings()).filter(p => p.enabled && p.configured);
|
||||
}
|
||||
|
||||
/** The setting value to store for an ordered list of { id, enabled }. */
|
||||
function serializeProviderSettings(list) {
|
||||
const ids = list.map(p => p.id).filter(id => getProvider(id));
|
||||
return JSON.stringify({ order: ids, enabled: Object.fromEntries(list.filter(p => getProvider(p.id)).map(p => [p.id, p.enabled === true])) });
|
||||
}
|
||||
|
||||
/** The payer's page for a checkout: /pay/<id> on the frontend. */
|
||||
function payPageUrl(checkoutId) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3000').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/pay/${encodeURIComponent(checkoutId)}`;
|
||||
}
|
||||
|
||||
/** Throws unless `provider` may take payments now (enabled; and set up, or it explains). */
|
||||
async function assertUsable(provider) {
|
||||
const state = (await providerSettings()).find(p => p.id === provider.id);
|
||||
if (!state?.enabled) throw paymentError(`Paying with ${provider.label} is turned off.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a recorded checkout with a provider. Idempotent for a provider that was already
|
||||
* started with a redirect (Yoco); form providers rebuild their (deterministic) form.
|
||||
* @returns {Promise<{ provider, redirectUrl, form? }>}
|
||||
*/
|
||||
async function startCheckout(checkout, provider) {
|
||||
if (checkout.provider === provider.id && checkout.redirectUrl) return { provider: provider.id, redirectUrl: checkout.redirectUrl };
|
||||
const request = checkout.request || {};
|
||||
const started = await provider.createCheckout({
|
||||
...request,
|
||||
checkoutId: checkout.id,
|
||||
amountCents: checkout.amountCents,
|
||||
currency: checkout.currency,
|
||||
metadata: checkout.metadata,
|
||||
});
|
||||
if (started.form) {
|
||||
if (checkout.provider !== provider.id) await prisma.providerCheckout.update({ where: { id: checkout.id }, data: { provider: provider.id } });
|
||||
return { provider: provider.id, redirectUrl: payPageUrl(checkout.id), form: started.form };
|
||||
}
|
||||
await prisma.providerCheckout.update({
|
||||
where: { id: checkout.id },
|
||||
data: { provider: provider.id, externalId: started.externalId || null, redirectUrl: started.redirectUrl },
|
||||
});
|
||||
return { provider: provider.id, redirectUrl: started.redirectUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a checkout with `providerId`, or the only available provider; with several
|
||||
* available, the payer chooses on /pay/<id>.
|
||||
* @param {object} input - { amountCents, metadata, lineItems, successUrl, cancelUrl, failureUrl,
|
||||
* payer?: { name, email }, description?, idempotencyKey? }
|
||||
* @returns {Promise<{ checkoutId, redirectUrl, provider: string|null }>}
|
||||
*/
|
||||
async function createCheckout(providerId, input) {
|
||||
if (!(input.amountCents >= MIN_ONLINE_AMOUNT * 100)) {
|
||||
throw paymentError(`Online payments must be at least R${MIN_ONLINE_AMOUNT}. Please pay at the door or contact the organiser.`);
|
||||
}
|
||||
let provider = null;
|
||||
const available = await availableProviders();
|
||||
if (providerId) {
|
||||
provider = getProvider(providerId);
|
||||
if (!provider) throw paymentError('Unknown payment provider.');
|
||||
await assertUsable(provider);
|
||||
} else if (available.length === 1) {
|
||||
provider = getProvider(available[0].id);
|
||||
} else if (available.length === 0) {
|
||||
// Nothing enabled: say so. Enabled but not set up: the provider explains what's missing.
|
||||
const enabled = (await providerSettings()).find(p => p.enabled);
|
||||
if (!enabled) throw paymentError('Online payments are turned off. Please pay at the door or contact the organiser.', 503);
|
||||
provider = getProvider(enabled.id);
|
||||
}
|
||||
|
||||
const { amountCents, metadata, currency, ...request } = input;
|
||||
const checkout = await prisma.providerCheckout.create({
|
||||
data: { amountCents, currency: currency || 'ZAR', metadata: metadata || {}, request },
|
||||
});
|
||||
if (!provider) return { checkoutId: checkout.id, redirectUrl: payPageUrl(checkout.id), provider: null };
|
||||
try {
|
||||
const started = await startCheckout(checkout, provider);
|
||||
return { checkoutId: checkout.id, redirectUrl: started.redirectUrl, provider: started.provider };
|
||||
} catch (e) {
|
||||
await prisma.providerCheckout.delete({ where: { id: checkout.id } }).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** The checkout a webhook is about: by our id, or by the provider's own checkout id. */
|
||||
async function findCheckout(providerId, ref) {
|
||||
if (!ref) return null;
|
||||
const ours = await prisma.providerCheckout.findUnique({ where: { id: String(ref) } }).catch(() => null);
|
||||
if (ours) return ours;
|
||||
return prisma.providerCheckout.findFirst({ where: { provider: providerId, externalId: String(ref) } });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROVIDERS, MIN_ONLINE_AMOUNT, SETTINGS_KEY, paymentError,
|
||||
getProvider, parseProviderSettings, serializeProviderSettings, providerSettings, availableProviders,
|
||||
payPageUrl, assertUsable, createCheckout, startCheckout, findCheckout,
|
||||
};
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* PayFast as a payment provider (see ../index.js for the interface), using PayFast's
|
||||
* "custom integration" (developers.payfast.co.za/docs → Custom Integration).
|
||||
*
|
||||
* Checkout: the payer's browser posts a signed form to https://<host>/eng/process (from our
|
||||
* /pay/<checkoutId> page). Fields go in the order PayFast lists them; the signature is the MD5
|
||||
* of the non-blank fields as name=value pairs (URL-encoded, upper-case escapes, spaces as '+')
|
||||
* joined with '&', plus &passphrase=… when one is set.
|
||||
*
|
||||
* ITN (Instant Transaction Notification): PayFast posts the result, form-encoded, to
|
||||
* /api/webhooks/payfast. Before trusting it we check, as PayFast requires: the signature, that
|
||||
* it came from a PayFast host, and — by posting it back to /eng/query/validate — that PayFast
|
||||
* says it's VALID. The amount is checked against the checkout in ../webhook.js.
|
||||
*
|
||||
* Status checks: when an ITN never arrives, findPayments looks the checkout up in PayFast's
|
||||
* transaction history (its API, signed differently — see apiSignature). Refunds aren't done
|
||||
* through the API: they're made in the PayFast dashboard and recorded with the manual flow.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const dns = require('dns').promises;
|
||||
const axios = require('axios');
|
||||
const { getPayfastConfig, payfastHost, payfastNotifyUrl } = require('../../utils/payfast');
|
||||
|
||||
// getTransaction: payments can be looked up in PayFast's transaction history (findPayments).
|
||||
const capabilities = { refund: false, getTransaction: true, listTransactions: false };
|
||||
|
||||
// Hosts PayFast sends ITNs from.
|
||||
const VALID_HOSTS = ['www.payfast.co.za', 'w1w.payfast.co.za', 'w2w.payfast.co.za', 'sandbox.payfast.co.za'];
|
||||
|
||||
async function isConfigured() {
|
||||
const c = await getPayfastConfig();
|
||||
return !!(c.merchantId && c.merchantKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-encodes a value the way PayFast's signature expects (PHP urlencode): every character
|
||||
* but letters, digits and -_. is %-escaped in upper case, and spaces become '+'. Checked
|
||||
* against the sandbox: PayFast's own Node sample, which leaves ' ( ) ! * ~ as they are, is
|
||||
* rejected for names like "Men's Camp (Youth)"; this is accepted.
|
||||
*/
|
||||
function pfEncode(value) {
|
||||
return encodeURIComponent(String(value).trim())
|
||||
.replace(/[!'()*~]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
||||
.replace(/%20/g, '+');
|
||||
}
|
||||
|
||||
/**
|
||||
* name=value&… of the pairs, in order. Pure. A checkout signs only the non-blank fields; an
|
||||
* ITN is checked over every field PayFast posted, blanks included (`allFields`).
|
||||
*/
|
||||
function paramString(pairs, { allFields = false } = {}) {
|
||||
const keep = allFields ? pairs : pairs.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '');
|
||||
return keep.map(([k, v]) => `${k}=${pfEncode(v ?? '')}`).join('&');
|
||||
}
|
||||
|
||||
/** PayFast's MD5 signature over ordered pairs (and the passphrase, if any). Pure. */
|
||||
function signature(pairs, passphrase = '', opts = {}) {
|
||||
let s = paramString(pairs, opts);
|
||||
if (passphrase) s += `&passphrase=${pfEncode(passphrase)}`;
|
||||
return crypto.createHash('md5').update(s).digest('hex');
|
||||
}
|
||||
|
||||
/** Text we send PayFast (item and payer names): one line, within PayFast's length limit. Pure. */
|
||||
function cleanText(value, max) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, max);
|
||||
}
|
||||
|
||||
/** "Camp ticket x2, T-shirt" → the item description (charged lines only). Pure. */
|
||||
function describeLines(lineItems) {
|
||||
return (lineItems || []).filter(l => l.unitCents > 0).map(l => `${l.name}${l.quantity > 1 ? ` x${l.quantity}` : ''}`).join(', ');
|
||||
}
|
||||
|
||||
async function createCheckout({ checkoutId, amountCents, successUrl, cancelUrl, payer, description, lineItems }) {
|
||||
const c = await getPayfastConfig();
|
||||
if (!c.merchantId || !c.merchantKey) {
|
||||
const err = new Error('PayFast isn\'t set up. An admin needs to add the merchant ID and key in Site Settings → Payments → PayFast.');
|
||||
err.statusCode = 503;
|
||||
throw err;
|
||||
}
|
||||
const notifyUrl = payfastNotifyUrl();
|
||||
const [first, ...rest] = String(payer?.name || '').trim().split(/\s+/);
|
||||
const email = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(String(payer?.email || '')) && !String(payer.email).endsWith('.local') && !String(payer.email).endsWith('.invalid') ? payer.email : '';
|
||||
const items = describeLines(lineItems);
|
||||
// In the order PayFast documents them — the signature depends on it.
|
||||
const pairs = [
|
||||
['merchant_id', c.merchantId],
|
||||
['merchant_key', c.merchantKey],
|
||||
['return_url', successUrl],
|
||||
['cancel_url', cancelUrl],
|
||||
['notify_url', notifyUrl],
|
||||
['name_first', cleanText(first, 100)],
|
||||
['name_last', cleanText(rest.join(' '), 100)],
|
||||
['email_address', email],
|
||||
['m_payment_id', checkoutId],
|
||||
['amount', (amountCents / 100).toFixed(2)],
|
||||
['item_name', cleanText(description || items || 'Payment', 100)],
|
||||
['item_description', cleanText(items, 255)],
|
||||
].filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '');
|
||||
const fields = Object.fromEntries(pairs.map(([k, v]) => [k, String(v).trim()]));
|
||||
fields.signature = signature(pairs, c.passphrase);
|
||||
return { form: { action: `https://${payfastHost(c.mode)}/eng/process`, fields } };
|
||||
}
|
||||
|
||||
/** The ITN's name/value pairs, decoded, in the order PayFast sent them. Pure. */
|
||||
function parsePairs(rawBody) {
|
||||
return String(rawBody || '').split('&').filter(Boolean).map(kv => {
|
||||
const i = kv.indexOf('=');
|
||||
const dec = (s) => decodeURIComponent(s.replace(/\+/g, ' '));
|
||||
return i < 0 ? [dec(kv), ''] : [dec(kv.slice(0, i)), dec(kv.slice(i + 1))];
|
||||
});
|
||||
}
|
||||
|
||||
function readWebhookBody(rawBody) {
|
||||
return Object.fromEntries(parsePairs(rawBody));
|
||||
}
|
||||
|
||||
/** The caller's IP, without IPv4-mapped-IPv6 noise (needs TRUST_PROXY behind a proxy). */
|
||||
function requestIp(req) {
|
||||
return String(req.ip || req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
|
||||
}
|
||||
|
||||
async function payfastIps() {
|
||||
const all = await Promise.all(VALID_HOSTS.map(h => dns.lookup(h, { all: true }).catch(() => [])));
|
||||
return new Set(all.flat().map(a => a.address));
|
||||
}
|
||||
|
||||
/** PayFast's four ITN checks, less the amount (checked against the checkout in ../webhook.js). */
|
||||
async function verifyWebhook(req, { lookupIps = payfastIps, post = axios.post } = {}) {
|
||||
const pairs = parsePairs(req.rawBody);
|
||||
const data = Object.fromEntries(pairs);
|
||||
if (!data.signature || !data.pf_payment_id) return { ok: false, status: 400, message: 'Not a PayFast notification' };
|
||||
|
||||
const c = await getPayfastConfig();
|
||||
if (!c.merchantId) return { ok: false, status: 500, message: 'PayFast isn\'t set up' };
|
||||
if (data.merchant_id && data.merchant_id !== c.merchantId) return { ok: false, status: 403, message: 'Notification for a different PayFast merchant' };
|
||||
|
||||
// 1. Signature: every posted field before the signature, in order, plus our passphrase.
|
||||
const signed = [];
|
||||
for (const pair of pairs) { if (pair[0] === 'signature') break; signed.push(pair); }
|
||||
if (signature(signed, c.passphrase, { allFields: true }) !== data.signature) {
|
||||
console.error('PayFast ITN: invalid signature');
|
||||
return { ok: false, status: 403, message: 'Invalid signature' };
|
||||
}
|
||||
|
||||
// 2. It came from PayFast.
|
||||
if (process.env.PAYFAST_SKIP_IP_CHECK !== 'true') {
|
||||
const ip = requestIp(req);
|
||||
if (!(await lookupIps()).has(ip)) {
|
||||
console.error(`PayFast ITN from ${ip}, which isn't a PayFast address (behind a proxy? set TRUST_PROXY)`);
|
||||
return { ok: false, status: 403, message: 'Notification did not come from PayFast' };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. PayFast confirms the details.
|
||||
let reply = '';
|
||||
try {
|
||||
const res = await post(`https://${payfastHost(c.mode)}/eng/query/validate`, paramString(signed, { allFields: true }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000 });
|
||||
reply = String(res.data || '').trim();
|
||||
} catch (e) {
|
||||
console.error('PayFast ITN: could not confirm with PayFast:', e.message);
|
||||
return { ok: false, status: 502, message: 'Could not confirm the notification with PayFast' };
|
||||
}
|
||||
if (reply !== 'VALID') {
|
||||
console.error('PayFast ITN: PayFast did not confirm it:', reply.slice(0, 100));
|
||||
return { ok: false, status: 403, message: 'PayFast did not confirm the notification' };
|
||||
}
|
||||
|
||||
return { ok: true, webhookId: data.pf_payment_id, timestamp: null };
|
||||
}
|
||||
|
||||
/** An ITN body → { type, transaction }. Pure. */
|
||||
function parseWebhook(body) {
|
||||
const status = String(body?.payment_status || '').toUpperCase();
|
||||
const gross = parseFloat(body?.amount_gross);
|
||||
return {
|
||||
type: status === 'COMPLETE' ? 'payment.succeeded' : `payment.${status.toLowerCase() || 'unknown'}`,
|
||||
transaction: {
|
||||
externalId: body?.pf_payment_id || null,
|
||||
amountCents: Number.isFinite(gross) ? Math.round(gross * 100) : null,
|
||||
currency: 'ZAR',
|
||||
createdDate: null,
|
||||
checkoutId: body?.m_payment_id || null,
|
||||
// PayFast doesn't say how the payer paid; it settles like a card payment.
|
||||
methodType: null,
|
||||
metadata: {},
|
||||
status: status === 'COMPLETE' ? 'succeeded' : status.toLowerCase(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── PayFast API (developers.payfast.co.za/api) ──────────────────────────────
|
||||
|
||||
const API_BASE = 'https://api.payfast.co.za';
|
||||
|
||||
/**
|
||||
* The API signature: every header and query variable plus the passphrase, sorted
|
||||
* alphabetically, URL-encoded, joined with '&' (not the payment form's field order). Pure.
|
||||
*/
|
||||
function apiSignature(vars) {
|
||||
const s = Object.keys(vars).sort()
|
||||
.filter(k => vars[k] !== undefined && vars[k] !== null && String(vars[k]) !== '')
|
||||
.map(k => `${k}=${pfEncode(vars[k])}`).join('&');
|
||||
return crypto.createHash('md5').update(s).digest('hex');
|
||||
}
|
||||
|
||||
/** A signed GET to the PayFast API; the sandbox adds ?testing=true (left out of the signature). */
|
||||
async function apiGet(path, query = {}, { get = axios.get } = {}) {
|
||||
const c = await getPayfastConfig();
|
||||
const headers = { 'merchant-id': c.merchantId, version: 'v1', timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00') };
|
||||
const signature = apiSignature({ ...headers, ...query, ...(c.passphrase ? { passphrase: c.passphrase } : {}) });
|
||||
const params = { ...query, ...(c.mode === 'live' ? {} : { testing: 'true' }) };
|
||||
try {
|
||||
const res = await get(`${API_BASE}${path}`, { params, headers: { ...headers, signature }, timeout: 20000, responseType: 'text', transformResponse: r => r });
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
const status = e.response?.status;
|
||||
let detail = '';
|
||||
try { detail = JSON.parse(e.response?.data || '{}')?.data?.response || ''; } catch { /* not JSON */ }
|
||||
const err = new Error(status === 401
|
||||
? 'PayFast refused the merchant ID or passphrase (check they match your PayFast dashboard, and the mode).'
|
||||
: `Could not reach PayFast${detail ? `: ${detail}` : status ? ` (${status})` : `: ${e.message}`}`);
|
||||
err.statusCode = status === 401 ? 400 : 502;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks the credentials with PayFast (GET /ping, signed). */
|
||||
async function testConnection(deps) {
|
||||
await apiGet('/ping', {}, deps);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Rows of a CSV with a header line, as objects keyed by the header. Pure. */
|
||||
function parseCsv(text) {
|
||||
const rows = [];
|
||||
let row = [], field = '', quoted = false;
|
||||
const s = String(text || '');
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (quoted) {
|
||||
if (ch === '"' && s[i + 1] === '"') { field += '"'; i++; }
|
||||
else if (ch === '"') quoted = false;
|
||||
else field += ch;
|
||||
} else if (ch === '"') quoted = true;
|
||||
else if (ch === ',') { row.push(field); field = ''; }
|
||||
else if (ch === '\n' || ch === '\r') {
|
||||
if (ch === '\r' && s[i + 1] === '\n') i++;
|
||||
row.push(field); field = '';
|
||||
if (row.some(v => v !== '')) rows.push(row);
|
||||
row = [];
|
||||
} else field += ch;
|
||||
}
|
||||
row.push(field);
|
||||
if (row.some(v => v !== '')) rows.push(row);
|
||||
const [header, ...data] = rows;
|
||||
return header ? data.map(r => Object.fromEntries(header.map((h, i) => [h, r[i] ?? '']))) : [];
|
||||
}
|
||||
|
||||
const day = (d) => new Date(d).toISOString().slice(0, 10);
|
||||
|
||||
/**
|
||||
* Payments PayFast received for our checkouts (from its transaction history, matched on
|
||||
* "M Payment ID" = our checkout id). For notifications (ITNs) that never arrived.
|
||||
* @param {object[]} checkouts - ProviderCheckout rows
|
||||
* @returns {Promise<Map<string, object>>} checkout id → transaction (as parseWebhook gives)
|
||||
*/
|
||||
async function findPayments(checkouts, deps) {
|
||||
const found = new Map();
|
||||
if (!checkouts.length) return found;
|
||||
const ids = new Set(checkouts.map(c => c.id));
|
||||
const from = day(Math.min(...checkouts.map(c => new Date(c.createdAt).getTime())) - 24 * 60 * 60 * 1000);
|
||||
const to = day(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const PAGE = 1000;
|
||||
for (let offset = 0; offset < 20 * PAGE; offset += PAGE) {
|
||||
const rows = parseCsv(await apiGet('/transactions/history', { from, to, limit: PAGE, offset }, deps));
|
||||
for (const r of rows) {
|
||||
const id = r['M Payment ID'];
|
||||
if (!ids.has(id) || r.Type !== 'FUNDS_RECEIVED' || found.has(id)) continue;
|
||||
const gross = parseFloat(String(r.Gross).replace(/,/g, ''));
|
||||
found.set(id, {
|
||||
externalId: r['PF Payment ID'] || null,
|
||||
amountCents: Number.isFinite(gross) ? Math.round(gross * 100) : null,
|
||||
currency: r.Currency || 'ZAR',
|
||||
// PayFast's history times are South African time
|
||||
createdDate: r.Date ? new Date(`${r.Date.replace(' ', 'T')}+02:00`) : null,
|
||||
checkoutId: id,
|
||||
methodType: null,
|
||||
metadata: {},
|
||||
status: 'succeeded',
|
||||
});
|
||||
}
|
||||
if (rows.length < PAGE || found.size === ids.size) break;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'payfast', label: 'PayFast', capabilities,
|
||||
isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody, findPayments, testConnection,
|
||||
// for tests
|
||||
pfEncode, paramString, signature, cleanText, parsePairs, VALID_HOSTS, apiSignature, parseCsv,
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Yoco as a payment provider (see ../index.js for the interface).
|
||||
*
|
||||
* Checkouts: POST /checkouts on the Yoco Checkout API; Yoco redirects the payer back and
|
||||
* posts a `payment.succeeded` webhook to /api/webhooks/yoco. Webhooks are signed with the
|
||||
* webhook secret (Standard Webhooks: HMAC-SHA256 over "id.timestamp.body").
|
||||
* Keys and webhooks are managed in Site settings → Payments → Yoco (utils/yoco.js).
|
||||
* Refunds and transaction lookups aren't done through Yoco's API here: refunds are made in
|
||||
* the Yoco portal and recorded with the manual refund flow.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { getYocoSecretKey, getYocoWebhookSecret, yocoRequest } = require('../../utils/yoco');
|
||||
|
||||
// Yoco recommends rejecting webhooks more than 3 minutes old (replays).
|
||||
const WEBHOOK_TOLERANCE_MS = 3 * 60 * 1000;
|
||||
|
||||
const capabilities = { refund: false, getTransaction: false, listTransactions: false };
|
||||
|
||||
async function isConfigured() {
|
||||
return !!(await getYocoSecretKey());
|
||||
}
|
||||
|
||||
/** Our line items → Yoco's (amounts in cents; a negative price is a deduction line). */
|
||||
function toYocoLineItems(lineItems) {
|
||||
return (lineItems || []).map(l => ({ displayName: l.name, quantity: l.quantity, pricingDetails: { price: l.unitCents } }));
|
||||
}
|
||||
|
||||
async function createCheckout({ amountCents, currency, metadata, lineItems, successUrl, cancelUrl, failureUrl, idempotencyKey }) {
|
||||
const data = await yocoRequest('post', '/checkouts', {
|
||||
amount: amountCents, currency, metadata, successUrl, cancelUrl, failureUrl, lineItems: toYocoLineItems(lineItems),
|
||||
}, undefined, idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {});
|
||||
return { externalId: data.id, redirectUrl: data.redirectUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* The signature Yoco sends for a webhook. Pure.
|
||||
* @param {string} secret - whsec_<base64>
|
||||
*/
|
||||
function webhookSignature(secret, id, timestamp, rawBody) {
|
||||
const secretBytes = Buffer.from(String(secret).split('_')[1] || '', 'base64');
|
||||
return crypto.createHmac('sha256', secretBytes).update(`${id}.${timestamp}.${rawBody}`).digest('base64');
|
||||
}
|
||||
|
||||
/** Checks the webhook headers, age and signature of an incoming request (req.rawBody). */
|
||||
async function verifyWebhook(req, now = Date.now()) {
|
||||
const headers = req.headers || {};
|
||||
const id = headers['webhook-id'];
|
||||
const timestamp = headers['webhook-timestamp'];
|
||||
const rawSigHeader = headers['webhook-signature'];
|
||||
if (!id || !timestamp || !rawSigHeader) {
|
||||
return {
|
||||
ok: false, status: 400, message: 'Missing webhook headers',
|
||||
details: { missingHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'].filter(h => !headers[h]) },
|
||||
};
|
||||
}
|
||||
|
||||
const webhookTimeMs = parseInt(timestamp, 10) * 1000;
|
||||
if (isNaN(webhookTimeMs) || Math.abs(now - webhookTimeMs) > WEBHOOK_TOLERANCE_MS) {
|
||||
return { ok: false, status: 400, message: 'Webhook timestamp outside acceptable window' };
|
||||
}
|
||||
|
||||
const secret = await getYocoWebhookSecret();
|
||||
if (!secret) {
|
||||
console.error('Yoco webhook secret is not configured (Site Settings → Payments → Yoco, or YOCO_WEBHOOK_SECRET)');
|
||||
return { ok: false, status: 500, message: 'Server configuration error', details: { error: 'Missing webhook secret configuration' } };
|
||||
}
|
||||
|
||||
const expected = webhookSignature(secret, id, timestamp, req.rawBody);
|
||||
// Accept if any of the space-separated signatures match (supports key rotation)
|
||||
const signatures = String(rawSigHeader).split(' ').map(s => s.split(',')[1]).filter(Boolean);
|
||||
const valid = signatures.some(sig => {
|
||||
try { return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)); } catch { return false; }
|
||||
});
|
||||
if (!valid) {
|
||||
console.error('Invalid webhook signature');
|
||||
return { ok: false, status: 403, message: 'Invalid signature', details: { error: 'Webhook signature verification failed' } };
|
||||
}
|
||||
return { ok: true, webhookId: id, timestamp };
|
||||
}
|
||||
|
||||
/** A Yoco webhook body → { type, transaction }. Pure. */
|
||||
function parseWebhook(body) {
|
||||
const payload = body?.payload || {};
|
||||
const metadata = payload.metadata || {};
|
||||
const createdDate = payload.createdDate || body?.createdDate;
|
||||
return {
|
||||
type: body?.type,
|
||||
transaction: {
|
||||
externalId: payload.id || body?.id || null,
|
||||
amountCents: typeof payload.amount === 'number' ? payload.amount : null,
|
||||
currency: payload.currency || null,
|
||||
createdDate: createdDate ? new Date(createdDate) : null,
|
||||
checkoutId: payload.checkoutId || metadata.checkoutId || metadata.checkout_id || null,
|
||||
methodType: payload.paymentMethodDetails?.type || null,
|
||||
metadata,
|
||||
status: payload.status || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'yoco', label: 'Yoco', capabilities,
|
||||
isConfigured, createCheckout, verifyWebhook, parseWebhook,
|
||||
// for tests
|
||||
webhookSignature, toYocoLineItems,
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Payment status checks — for when a provider's notification (webhook / ITN) never arrives.
|
||||
*
|
||||
* An open checkout is one we started with a provider that has no payment recorded yet. A
|
||||
* provider that can look payments up (capabilities.getTransaction, findPayments) is asked
|
||||
* whether it received one; anything it found is settled exactly as its webhook would have been
|
||||
* (same amount check, same recording, same emails). Run every 15 minutes by the
|
||||
* 'payment-status' job for recent checkouts, and on demand from the Payments page.
|
||||
* Providers that can't be asked (Yoco: its API has no lookup) show as "can't check" — their
|
||||
* payments are found in the provider's portal and recorded with Reconcile.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { getProvider } = require('./index');
|
||||
const { settlePayment } = require('./webhook');
|
||||
|
||||
/** Whether a checkout has been paid: a payment came back for it, or its registration is paid. */
|
||||
async function checkoutPaid(checkout) {
|
||||
const refs = [checkout.id, checkout.externalId].filter(Boolean);
|
||||
const tx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } });
|
||||
if (tx) return true;
|
||||
const registrationId = checkout.metadata?.registrationId;
|
||||
if (!registrationId) return false;
|
||||
const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } });
|
||||
return reg?.status === 'paid';
|
||||
}
|
||||
|
||||
/** A payment held for review on this checkout (paid a different amount), if any. */
|
||||
async function heldForReview(checkout) {
|
||||
const refs = [checkout.id, checkout.externalId].filter(Boolean);
|
||||
return prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: null, ignored: false, note: { not: null } }, select: { id: true, note: true } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks each checkout's provider whether it was paid and records what it finds.
|
||||
* @returns {Promise<Map<string, { status: 'paid'|'recorded'|'needs_review'|'not_paid'|'cant_check', note? }>>}
|
||||
*/
|
||||
async function checkCheckouts(checkouts) {
|
||||
const out = new Map();
|
||||
const byProvider = new Map();
|
||||
for (const c of checkouts) {
|
||||
if (await checkoutPaid(c)) { out.set(c.id, { status: 'paid' }); continue; }
|
||||
const provider = getProvider(c.provider);
|
||||
if (!provider?.findPayments) { out.set(c.id, { status: 'cant_check' }); continue; }
|
||||
if (!byProvider.has(provider.id)) byProvider.set(provider.id, []);
|
||||
byProvider.get(provider.id).push(c);
|
||||
}
|
||||
for (const [providerId, list] of byProvider) {
|
||||
const provider = getProvider(providerId);
|
||||
const found = await provider.findPayments(list);
|
||||
for (const c of list) {
|
||||
const tx = found.get(c.id);
|
||||
if (!tx) {
|
||||
const held = await heldForReview(c);
|
||||
out.set(c.id, held ? { status: 'needs_review', note: held.note } : { status: 'not_paid' });
|
||||
continue;
|
||||
}
|
||||
const event = {
|
||||
type: 'payment.succeeded',
|
||||
transaction: { ...tx, metadata: { ...(c.metadata || {}), ...(tx.metadata || {}) } },
|
||||
checkoutRefs: [c.id, c.externalId].filter(Boolean),
|
||||
};
|
||||
const settled = await settlePayment(provider, event, { source: 'status-check', provider: providerId, transaction: tx }, c);
|
||||
out.set(c.id, settled.needsReview ? { status: 'needs_review', note: settled.note } : { status: 'recorded' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Recent checkouts started with a provider and not paid yet, newest first. */
|
||||
async function openCheckouts({ since, until = new Date() } = {}) {
|
||||
const rows = await prisma.providerCheckout.findMany({
|
||||
where: { provider: { not: null }, createdAt: { gte: since, lte: until } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
const open = [];
|
||||
for (const c of rows) if (!(await checkoutPaid(c))) open.push(c);
|
||||
return open;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background job: checks checkouts started between 3 days and 10 minutes ago (giving the
|
||||
* webhook time to arrive first) with providers that can be asked.
|
||||
*/
|
||||
async function runPaymentStatusJob(now = new Date()) {
|
||||
const since = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||
const until = new Date(now.getTime() - 10 * 60 * 1000);
|
||||
const checkable = (await openCheckouts({ since, until })).filter(c => getProvider(c.provider)?.findPayments);
|
||||
if (!checkable.length) return { checked: 0, recorded: 0, needsReview: 0 };
|
||||
const results = [...(await checkCheckouts(checkable)).values()];
|
||||
return {
|
||||
checked: checkable.length,
|
||||
recorded: results.filter(r => r.status === 'recorded').length,
|
||||
needsReview: results.filter(r => r.status === 'needs_review').length,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { checkoutPaid, checkCheckouts, openCheckouts, runPaymentStatusJob };
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Payment webhooks, for every provider: the provider checks the signature and reads the
|
||||
* body (providers/*.js); everything after that is shared here.
|
||||
*
|
||||
* - Every event is stored as a ProviderTransaction (by provider + external id, so repeats
|
||||
* are idempotent). Events that aren't for one of our checkouts stay unreconciled for the
|
||||
* Payments page.
|
||||
* - The event is matched to the checkout we started (ProviderCheckout, by our id or the
|
||||
* provider's): that gives who/what it's for, and the amount we asked for. A payment of a
|
||||
* different amount isn't recorded — it waits on the Payments page with a note.
|
||||
* - payment.succeeded for a registration or donation records a Payment at the full amount
|
||||
* paid (provider fees are dealt with at cashup), splits off any donation added at checkout,
|
||||
* updates the registration's status and generates tickets once it's paid, then emails.
|
||||
* - A provider that has been switched off still has its webhooks processed, so checkouts
|
||||
* started before it was turned off complete.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { getProvider, findCheckout } = require('./index');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||
|
||||
/** Express handler for POST /api/webhooks/<providerId> (needs req.rawBody). */
|
||||
const handleProviderWebhook = (providerId) => async (req, res) => {
|
||||
try {
|
||||
const provider = getProvider(providerId);
|
||||
if (!provider) return res.status(404).json({ success: false, message: 'Unknown payment provider' });
|
||||
|
||||
const verified = await provider.verifyWebhook(req);
|
||||
if (!verified.ok) {
|
||||
return res.status(verified.status).json({ success: false, message: verified.message, ...(verified.details ? { details: verified.details } : {}) });
|
||||
}
|
||||
|
||||
const body = provider.readWebhookBody ? provider.readWebhookBody(req.rawBody) : JSON.parse(req.rawBody);
|
||||
const event = provider.parseWebhook(body);
|
||||
const tx = event.transaction;
|
||||
const receivedCheckoutId = tx.checkoutId;
|
||||
|
||||
// The checkout we started: its metadata says who/what this is for (PayFast sends none back)
|
||||
const checkout = await findCheckout(provider.id, tx.checkoutId).catch(() => null);
|
||||
if (checkout) tx.metadata = { ...(checkout.metadata || {}), ...(tx.metadata || {}) };
|
||||
// A registration remembers our checkout id (older ones: the provider's)
|
||||
event.checkoutRefs = [...new Set([checkout?.id, tx.checkoutId].filter(Boolean))];
|
||||
|
||||
// Optional filtering: only process specific checkoutIds or prefixes on this endpoint
|
||||
try {
|
||||
const idsEnv = process.env.YOCO_ALLOWED_CHECKOUT_IDS || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_IDS || '';
|
||||
const prefixesEnv = process.env.YOCO_ALLOWED_CHECKOUT_PREFIXES || process.env.YOCO_WEBHOOK_ALLOWED_CHECKOUT_PREFIXES || '';
|
||||
const allowedIds = idsEnv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const allowedPrefixes = prefixesEnv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (allowedIds.length > 0 || allowedPrefixes.length > 0) {
|
||||
const idMatches = receivedCheckoutId && (allowedIds.includes(receivedCheckoutId) || allowedPrefixes.some(pref => receivedCheckoutId.startsWith(pref)));
|
||||
if (!idMatches) {
|
||||
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to store filtered webhook event:', e?.message || e); }
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook acknowledged but ignored due to unmatched checkoutId for this endpoint',
|
||||
data: { filtered: true, receivedCheckoutId, allowedIdsCount: allowedIds.length, allowedPrefixesCount: allowedPrefixes.length },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (filterErr) {
|
||||
console.warn('Webhook checkoutId filter parse warning:', filterErr?.message || filterErr);
|
||||
}
|
||||
|
||||
// Not one of our registration checkouts and not a donation → keep it for reconciling
|
||||
try {
|
||||
if (receivedCheckoutId) {
|
||||
const existingRegistration = await prisma.registration.findFirst({ where: { checkoutId: { in: event.checkoutRefs } }, select: { id: true } });
|
||||
if (!existingRegistration) {
|
||||
const meta = tx.metadata || {};
|
||||
const isDonation = meta.type === 'donation' || !!meta.isDonation || !!meta.eventId;
|
||||
if (!isDonation) {
|
||||
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to save unreconciled transaction:', e?.message || e); }
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook acknowledged and stored as unreconciled: no registration found for checkoutId',
|
||||
data: { filtered: true, reason: 'no_registration_for_checkoutId', receivedCheckoutId },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (dbFilterErr) {
|
||||
console.warn('Webhook DB auto-filter warning:', dbFilterErr?.message || dbFilterErr);
|
||||
}
|
||||
|
||||
if (event.type !== 'payment.succeeded') {
|
||||
console.log(`Unhandled ${provider.id} webhook event type: ${event.type}`);
|
||||
try { await saveProviderTransaction(provider.id, event, body); } catch (e) { console.warn('Failed to store unhandled webhook event:', e?.message || e); }
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received but event type not handled',
|
||||
data: { eventType: event.type, webhookId: verified.webhookId, timestamp: verified.timestamp },
|
||||
});
|
||||
}
|
||||
|
||||
const settled = await settlePayment(provider, event, body, checkout);
|
||||
if (settled.needsReview) {
|
||||
return res.status(200).json({ success: true, message: 'Webhook acknowledged; amount differs from the checkout, so it needs review', data: { needsReview: true } });
|
||||
}
|
||||
const processedData = settled.processedData;
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: `Successfully processed ${event.type} webhook`,
|
||||
data: { eventType: event.type, webhookId: verified.webhookId, timestamp: verified.timestamp, processedData },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Webhook error:', error);
|
||||
return res.status(500).json({ success: false, message: error.message, details: { error: error.stack, timestamp: new Date().toISOString() } });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A successful payment for one of our checkouts (from a webhook, or found by a status check):
|
||||
* held for review with a note when the amount differs from the checkout, otherwise recorded.
|
||||
* @returns {Promise<{ needsReview: true, note } | { needsReview: false, processedData }>}
|
||||
*/
|
||||
async function settlePayment(provider, event, body, checkout) {
|
||||
const tx = event.transaction;
|
||||
if (checkout && tx.amountCents !== checkout.amountCents) {
|
||||
const note = `Paid R${((tx.amountCents || 0) / 100).toFixed(2)}, but the checkout was for R${(checkout.amountCents / 100).toFixed(2)}. Check it with ${provider.label} before recording it.`;
|
||||
console.warn(`[${provider.id}] amount mismatch on checkout ${checkout.id}: ${note}`);
|
||||
try { await saveProviderTransaction(provider.id, event, body, { note }); } catch (e) { console.warn('Failed to store mismatched payment:', e?.message || e); }
|
||||
return { needsReview: true, note };
|
||||
}
|
||||
return { needsReview: false, processedData: await recordSucceededPayment(provider.id, event, body) };
|
||||
}
|
||||
|
||||
const REG_INCLUDE ={ event: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } } };
|
||||
|
||||
/** Records a successful payment from a webhook. Idempotent on the provider's payment id. */
|
||||
async function recordSucceededPayment(providerId, event, body) {
|
||||
const { externalId, amountCents, status, metadata = {}, methodType, checkoutId } = event.transaction;
|
||||
console.log(`Payment succeeded (${providerId}):`, externalId);
|
||||
|
||||
const existingPayment = await prisma.payment.findFirst({ where: { externalId } });
|
||||
if (existingPayment) {
|
||||
console.log(`Payment ${externalId} already processed`);
|
||||
try { await saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: existingPayment.id }); } catch (e) { console.warn('Failed to reconcile transaction for existing payment:', e?.message || e); }
|
||||
return { status: 'already_processed', paymentId: existingPayment.id, externalId };
|
||||
}
|
||||
|
||||
let registration = null;
|
||||
const refs = event.checkoutRefs || [checkoutId].filter(Boolean);
|
||||
if (refs.length) registration = await prisma.registration.findFirst({ where: { checkoutId: { in: refs } }, include: REG_INCLUDE });
|
||||
if (!registration && metadata.registrationId) registration = await prisma.registration.findUnique({ where: { id: metadata.registrationId }, include: REG_INCLUDE });
|
||||
|
||||
// Resolve userId: registration owner → metadata → first active admin (never a non-existent UUID)
|
||||
let resolvedUserId = registration?.userId || metadata.userId || null;
|
||||
if (!resolvedUserId) {
|
||||
try {
|
||||
const adminUser = await prisma.user.findFirst({ where: { role: 'admin', isActive: true }, select: { id: true } });
|
||||
resolvedUserId = adminUser?.id || null;
|
||||
} catch { /* 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 = amountCents / 100;
|
||||
const donationPart = registration?.id ? Math.min(Math.max(Number(metadata.donationAmount) || 0, 0), grossAmount) : 0;
|
||||
const method = methodType || 'card';
|
||||
const paymentStatus = status === 'succeeded' ? 'completed' : status;
|
||||
|
||||
const payment = await prisma.payment.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
amount: Math.round((grossAmount - donationPart) * 100) / 100,
|
||||
method,
|
||||
provider: providerId,
|
||||
status: paymentStatus,
|
||||
externalId,
|
||||
registrationId: registration?.id || null,
|
||||
userId: resolvedUserId,
|
||||
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
|
||||
eventId: registration?.eventId || metadata.eventId || null,
|
||||
isDonation: !registration?.id,
|
||||
...(!registration?.id && metadata.type === 'sponsorship' ? {
|
||||
isSponsorship: true,
|
||||
sponsorAnonymous: String(metadata.sponsorAnonymous) === 'true',
|
||||
sponsorName: String(metadata.sponsorAnonymous) === 'true' ? null : (metadata.sponsorName ? String(metadata.sponsorName).slice(0, 100) : null),
|
||||
sponsorMessage: metadata.sponsorMessage ? String(metadata.sponsorMessage).slice(0, 300) : null,
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
|
||||
let donationPayment = null;
|
||||
if (donationPart > 0) {
|
||||
donationPayment = await prisma.payment.create({
|
||||
data: {
|
||||
id: uuidv4(), amount: donationPart, method, provider: providerId, status: paymentStatus,
|
||||
userId: resolvedUserId, recordedById: resolvedUserId, registrationId: null, eventId: registration.eventId,
|
||||
isDonation: true, originalPaymentId: payment.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let updatedRegistration = null;
|
||||
let generatedTickets = [];
|
||||
if (registration) {
|
||||
updatedRegistration = await updateRegistrationStatus(registration.id);
|
||||
if (updatedRegistration.status === 'paid') {
|
||||
generatedTickets = await prisma.ticket.findMany({
|
||||
where: { registrationOption: { registrationId: registration.id } },
|
||||
select: { id: true, qrCode: true, isUsed: true, registrationOptionId: true, eventId: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Payment ${externalId} processed successfully`);
|
||||
try { await saveProviderTransaction(providerId, event, body, { reconciled: true, paymentId: payment.id }); } catch (e) { console.warn('Failed to reconcile transaction after payment create:', e?.message || e); }
|
||||
|
||||
// Emails: payment confirmation first, then tickets (guarantees order)
|
||||
const regUserId = registration?.userId;
|
||||
const regId = registration?.id;
|
||||
const sendTickets = generatedTickets.length > 0;
|
||||
(async () => {
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
try { await sendPaymentEmails(payment.id); } catch (e) { console.error('Failed to send webhook payment emails:', e); }
|
||||
if (donationPayment) {
|
||||
try { await sendPaymentEmails(donationPayment.id); } catch (e) { console.error('Failed to send webhook donation emails:', e); }
|
||||
}
|
||||
if (sendTickets && regUserId && regId) {
|
||||
const { emailTickets } = require('../controllers/ticketController');
|
||||
const mockReq = { user: { id: regUserId }, body: { registrationId: regId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets from webhook:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
payment: { id: payment.id, externalId, amount: payment.amount, method: payment.method, status: payment.status, createdAt: payment.createdAt },
|
||||
registration: registration ? {
|
||||
id: registration.id,
|
||||
status: updatedRegistration ? updatedRegistration.status : registration.status,
|
||||
eventId: registration.eventId,
|
||||
eventTitle: registration.event?.title,
|
||||
userId: registration.userId,
|
||||
userName: registration.user?.name,
|
||||
} : null,
|
||||
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sets a registration's status from its payments; generates tickets once it's paid. */
|
||||
async function updateRegistrationStatus(registrationId) {
|
||||
// Refresh early-bird pricing first so a registration is never marked paid on stale prices.
|
||||
try { await refreshPricingForRegistration(registrationId); } catch (e) { console.warn('[webhook] Price refresh failed:', e?.message); }
|
||||
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
|
||||
});
|
||||
if (!registration) throw new Error(`Registration ${registrationId} not found`);
|
||||
|
||||
const totalPaid = registration.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
const newStatus = totalPaid >= totalDue ? 'paid' : totalPaid > 0 ? 'partial_paid' : 'pending';
|
||||
|
||||
const updatedRegistration = await prisma.registration.update({ where: { id: registrationId }, data: { status: newStatus, updatedAt: new Date() } });
|
||||
|
||||
// Tickets are generated here; the caller emails them after the payment email.
|
||||
let generatedTickets = [];
|
||||
if (newStatus === 'paid') {
|
||||
try { generatedTickets = await generateTicketsForRegistration(registrationId); } catch (error) { console.error('Error generating tickets:', error); }
|
||||
}
|
||||
return { ...updatedRegistration, totalPaid, totalDue, generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined };
|
||||
}
|
||||
|
||||
/** Stores (or updates) the transaction behind a webhook event. Idempotent per provider + id. */
|
||||
async function saveProviderTransaction(providerId, event, body, updates = {}) {
|
||||
const tx = event.transaction;
|
||||
const externalId = tx.externalId;
|
||||
if (!externalId) throw new Error('Missing external id in webhook payload');
|
||||
const where = { provider_externalId: { provider: providerId, externalId } };
|
||||
const touch = (existing) => (Object.keys(updates).length
|
||||
? prisma.providerTransaction.update({ where: { id: existing.id }, data: updates })
|
||||
: existing);
|
||||
try {
|
||||
const existing = await prisma.providerTransaction.findUnique({ where });
|
||||
if (existing) return touch(existing);
|
||||
return await prisma.providerTransaction.create({
|
||||
data: {
|
||||
provider: providerId, externalId,
|
||||
amount: tx.amountCents, currency: tx.currency, createdDate: tx.createdDate,
|
||||
checkoutId: tx.checkoutId, methodType: tx.methodType,
|
||||
reconciled: false, raw: body, ...updates,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// Unique constraint race: another request stored it first
|
||||
if (e?.code === 'P2002') {
|
||||
const existing = await prisma.providerTransaction.findUnique({ where });
|
||||
return existing ? touch(existing) : null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleProviderWebhook, settlePayment, recordSucceededPayment, updateRegistrationStatus, saveProviderTransaction };
|
||||
@@ -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,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getConfig, updateConfig, testConnection } = require('../controllers/payfastController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/config', protect, admin, getConfig);
|
||||
router.put('/config', protect, admin, updateConfig);
|
||||
router.post('/test', protect, admin, testConnection);
|
||||
|
||||
module.exports = router;
|
||||
@@ -9,17 +9,28 @@ const {
|
||||
getPaymentsByEvent,
|
||||
assignDonationToRegistration,
|
||||
unassignDonationFromRegistration,
|
||||
createYocoCheckout,
|
||||
createCheckout,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
} = require('../controllers/paymentController');
|
||||
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController');
|
||||
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
|
||||
|
||||
// Protected routes
|
||||
router.post('/', protect, supervisor, createPayment);
|
||||
router.post('/yoco-checkout', protect, createYocoCheckout);
|
||||
router.get('/providers', listPaymentProviders);
|
||||
router.get('/pay/:id', getPayPage);
|
||||
router.post('/pay/:id', choosePayProvider);
|
||||
router.get('/checkouts/open', protect, supervisor, listOpenCheckouts);
|
||||
router.post('/checkouts/check', protect, supervisor, checkCheckoutStatus);
|
||||
router.get('/providers/settings', protect, admin, getPaymentProviderSettings);
|
||||
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
|
||||
router.post('/checkout', protect, createCheckout);
|
||||
router.post('/checkout/send', protect, supervisor, sendPaymentLink);
|
||||
// Older names, kept for existing clients
|
||||
router.post('/yoco-checkout', protect, createCheckout);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.get('/mypayments', protect, getUserPayments);
|
||||
router.post('/:id/send-receipt', protect, sendReceipt);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getMyPrivacy,
|
||||
updateMyNews,
|
||||
exportMyData,
|
||||
createMyRequest,
|
||||
getUnsubscribe,
|
||||
postUnsubscribe,
|
||||
oneClickUnsubscribe,
|
||||
oneClickRedirect,
|
||||
listRequests,
|
||||
completeRequest,
|
||||
rejectRequest,
|
||||
} = require('../controllers/privacyController');
|
||||
const {
|
||||
getNotice,
|
||||
acceptMyNotice,
|
||||
getNoticeDraft,
|
||||
saveNoticeDraft,
|
||||
discardNoticeDraft,
|
||||
publishNotice,
|
||||
listOperators,
|
||||
createOperator,
|
||||
updateOperator,
|
||||
deleteOperator,
|
||||
consentLog,
|
||||
accessLog,
|
||||
} = require('../controllers/privacyAdminController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Exports are heavy-ish (every record the person has) — a few per 10 minutes is plenty.
|
||||
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
// The person themselves
|
||||
router.get('/me', protect, getMyPrivacy);
|
||||
router.put('/me/news', protect, updateMyNews);
|
||||
router.get('/me/export', protect, exportLimiter, exportMyData);
|
||||
router.post('/me/requests', protect, createMyRequest);
|
||||
|
||||
// Unsubscribe links (signed tokens, no login)
|
||||
router.get('/unsubscribe/:token', getUnsubscribe);
|
||||
router.post('/unsubscribe/:token', postUnsubscribe);
|
||||
router.get('/unsubscribe/:token/one-click', oneClickRedirect);
|
||||
router.post('/unsubscribe/:token/one-click', express.urlencoded({ extended: false }), oneClickUnsubscribe);
|
||||
|
||||
// Admin: data requests inbox
|
||||
router.get('/requests', protect, admin, listRequests);
|
||||
router.post('/requests/:id/complete', protect, admin, completeRequest);
|
||||
router.post('/requests/:id/reject', protect, admin, rejectRequest);
|
||||
|
||||
// Privacy notice: public reading, acceptance, and the admin editor
|
||||
router.get('/notice', getNotice);
|
||||
router.post('/me/notice/accept', protect, acceptMyNotice);
|
||||
router.get('/notice/draft', protect, admin, getNoticeDraft);
|
||||
router.put('/notice/draft', protect, admin, saveNoticeDraft);
|
||||
router.delete('/notice/draft', protect, admin, discardNoticeDraft);
|
||||
router.post('/notice/publish', protect, admin, publishNotice);
|
||||
|
||||
// Admin: operator register, consent log, access log
|
||||
router.get('/operators', protect, admin, listOperators);
|
||||
router.post('/operators', protect, admin, createOperator);
|
||||
router.put('/operators/:id', protect, admin, updateOperator);
|
||||
router.delete('/operators/:id', protect, admin, deleteOperator);
|
||||
router.get('/consent-log', protect, admin, consentLog);
|
||||
router.get('/access-log', protect, admin, accessLog);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,20 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAllProviderTransactions, getUnreconciledProviderTransactions, reconcileProviderTransaction, ignoreProviderTransaction } = require('../controllers/providerTransactionsController');
|
||||
const { protect, supervisor} = require('../middleware/authMiddleware');
|
||||
|
||||
// Mounted at /api/provider-transactions (and /api/yoco-transactions, the older name).
|
||||
|
||||
// GET all payment provider transactions (admin/supervisor)
|
||||
router.get('/', protect, supervisor, getAllProviderTransactions);
|
||||
|
||||
// GET all unreconciled transactions (admin/supervisor)
|
||||
router.get('/unreconciled', protect, supervisor, getUnreconciledProviderTransactions);
|
||||
|
||||
// POST reconcile a transaction
|
||||
router.post('/:id/reconcile', protect, supervisor, reconcileProviderTransaction);
|
||||
|
||||
// POST ignore a transaction
|
||||
router.post('/:id/ignore', protect, supervisor, ignoreProviderTransaction);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,9 +1,11 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
|
||||
const { handleProviderWebhook } = require('../payments/webhook');
|
||||
|
||||
// Yoco webhook endpoint
|
||||
// Payment provider webhooks (src/payments)
|
||||
router.post('/yoco', handleYocoWebhook);
|
||||
router.post('/payfast', handleProviderWebhook('payfast'));
|
||||
router.post('/whatsapp', handleWhatsappWebhook);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,18 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAllYocoTransactions, getUnreconciledYocoTransactions, reconcileYocoTransaction, ignoreYocoTransaction } = require('../controllers/yocoTransactionsController');
|
||||
const { protect, supervisor} = require('../middleware/authMiddleware');
|
||||
|
||||
// GET all Yoco transactions (admin/supervisor)
|
||||
router.get('/', protect, supervisor, getAllYocoTransactions);
|
||||
|
||||
// GET all unreconciled Yoco transactions (admin/supervisor)
|
||||
router.get('/unreconciled', protect, supervisor, getUnreconciledYocoTransactions);
|
||||
|
||||
// POST reconcile a Yoco transaction
|
||||
router.post('/:id/reconcile', protect, supervisor, reconcileYocoTransaction);
|
||||
|
||||
// POST ignore a Yoco transaction
|
||||
router.post('/:id/ignore', protect, supervisor, ignoreYocoTransaction);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,51 @@
|
||||
const prisma = require('../config/db');
|
||||
const { getClientIp } = require('./requestUtils');
|
||||
|
||||
// What each DataAccessLog action means, for the admin page.
|
||||
const ACCESS_ACTIONS = {
|
||||
report_pdf: 'Report downloaded (PDF)',
|
||||
report_excel: 'Report downloaded (Excel)',
|
||||
report_email: 'Report emailed',
|
||||
report_whatsapp: 'Report sent by WhatsApp',
|
||||
attendance_export: 'Attendance register exported',
|
||||
certificates_download: 'Certificates downloaded',
|
||||
group_lists: 'Room/group lists opened',
|
||||
form_responses: 'Attendee form answers viewed',
|
||||
consent_list: 'Parental consent list viewed',
|
||||
medical_list: 'Medical list viewed',
|
||||
backup_download: 'Database backup downloaded',
|
||||
impersonation: 'Viewed the site as another user',
|
||||
};
|
||||
|
||||
/**
|
||||
* POPIA access log: someone exported or viewed personal information in bulk. Fire-and-
|
||||
* forget like logAdminAction — a logging failure never blocks the export itself.
|
||||
*/
|
||||
async function logDataAccess(req, { action, what, eventId = null, records = null }) {
|
||||
try {
|
||||
await prisma.dataAccessLog.create({
|
||||
data: {
|
||||
actorId: req.user?.id || null,
|
||||
actorRole: req.user?.role || 'unknown',
|
||||
action,
|
||||
what: String(what || ACCESS_ACTIONS[action] || action).slice(0, 500),
|
||||
eventId: eventId || null,
|
||||
records: Number.isFinite(records) ? records : null,
|
||||
ip: getClientIp(req),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to log data access:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows in a report payload (main table or layered sections, plus extra tables). */
|
||||
function reportRowCount(payload) {
|
||||
const p = payload || {};
|
||||
let n = Array.isArray(p.table?.rows) ? p.table.rows.length : 0;
|
||||
if (Array.isArray(p.layered?.sections)) n += p.layered.sections.reduce((s, sec) => s + (Array.isArray(sec.items) ? sec.items.length : 0), 0);
|
||||
if (Array.isArray(p.extraTables)) n += p.extraTables.reduce((s, t) => s + (Array.isArray(t.rows) ? t.rows.length : 0), 0);
|
||||
return n;
|
||||
}
|
||||
|
||||
module.exports = { ACCESS_ACTIONS, logDataAccess, reportRowCount };
|
||||
@@ -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, sessionIds: 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,
|
||||
|
||||
@@ -54,7 +54,7 @@ function isSmtpConfigured() {
|
||||
return !!_smtpConfig().host;
|
||||
}
|
||||
|
||||
async function sendMail({ to, subject, html, text, attachments }) {
|
||||
async function sendMail({ to, subject, html, text, attachments, headers }) {
|
||||
// Never send to anonymised/deleted accounts
|
||||
if (!to || String(to).endsWith('@deleted.invalid')) {
|
||||
console.info('[email] Skipping send to deleted account:', to);
|
||||
@@ -62,7 +62,7 @@ async function sendMail({ to, subject, html, text, attachments }) {
|
||||
}
|
||||
const { transporter, cfg } = _getTransporter();
|
||||
const from = cfg.from || 'no-reply@crosscode.local';
|
||||
const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) });
|
||||
const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}), ...(headers ? { headers } : {}) });
|
||||
|
||||
if (transporter.options && transporter.options.jsonTransport) {
|
||||
try {
|
||||
@@ -420,9 +420,27 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain message about a privacy (POPIA) request: a heading and paragraphs. Paragraphs
|
||||
* are text (escaped here), so an admin's typed answer can go straight in.
|
||||
*/
|
||||
function buildPrivacyEmail({ name, heading, paragraphs }) {
|
||||
const org = getOrg();
|
||||
const esc = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br/>');
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 24px 0;letter-spacing:-0.3px">${esc(heading)}</p>
|
||||
<p style="margin:0 0 16px 0;color:#374151">Hi <strong>${esc(name || 'there')}</strong>,</p>
|
||||
${paragraphs.map(p => `<p style="margin:0 0 16px 0;color:#374151">${esc(p)}</p>`).join('')}
|
||||
<p style="margin:0;color:#374151">Questions? Contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.</p>`;
|
||||
const text = `Hi ${name || 'there'},\n\n${paragraphs.join('\n\n')}\n\nQuestions? Contact us at ${org.email}.\n\n${org.name}`;
|
||||
return { text, html: emailWrapper(body, { preheader: heading }) };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildPrivacyEmail,
|
||||
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 })}
|
||||
@@ -956,12 +955,12 @@ function buildDailySummary(ev, registrations, payments, now, waitlistCount = 0)
|
||||
|
||||
/**
|
||||
* Generates an invoice PDF for a registration when it still has an outstanding balance,
|
||||
* reusing (or, if none was supplied, creating) a Yoco checkout link so the invoice can carry
|
||||
* reusing (or, if none was supplied, creating) a checkout link so the invoice can carry
|
||||
* a clickable "pay now" link and a scannable QR code. Returns null when nothing is owed or
|
||||
* PDF generation fails — callers fall back to the existing text-only notification either way.
|
||||
*/
|
||||
/**
|
||||
* Resolves a Yoco checkout link for an invoice's "pay now" link/QR code. Returns the given
|
||||
* Resolves a checkout link for an invoice's "pay now" link/QR code. Returns the given
|
||||
* hint as-is when supplied; otherwise creates a fresh checkout, or null if nothing is owed
|
||||
* (no point paying) or checkout creation fails (invoice still generates, just without a link).
|
||||
*/
|
||||
@@ -969,16 +968,16 @@ async function resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHi
|
||||
if (paymentUrlHint) return paymentUrlHint;
|
||||
if (totalDue - totalPaid <= 0.01) return null;
|
||||
try {
|
||||
const { createRegistrationCheckoutInternal } = require('../controllers/paymentController');
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts');
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const checkout = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
|
||||
const checkout = await createRegistrationCheckout(reg.id, reg.userId, {
|
||||
successUrl: `${baseUrl}/payment/success`,
|
||||
cancelUrl: `${baseUrl}/payment/cancel`,
|
||||
failureUrl: `${baseUrl}/payment/failure`,
|
||||
});
|
||||
return checkout.redirectUrl || null;
|
||||
} catch (e) {
|
||||
console.warn('Could not create Yoco checkout for invoice PDF:', e?.message || e);
|
||||
console.warn('Could not create a checkout for the invoice PDF:', e?.message || e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* PayFast configuration (Admin → Site Settings → Payments → PayFast).
|
||||
*
|
||||
* Stored in AppSetting: payfast_merchant_id, payfast_merchant_key, payfast_passphrase
|
||||
* (AES-256-GCM encrypted — it signs every payment) and payfast_mode ('sandbox' | 'live',
|
||||
* default sandbox). PAYFAST_MERCHANT_ID / PAYFAST_MERCHANT_KEY / PAYFAST_PASSPHRASE /
|
||||
* PAYFAST_MODE in .env are fallbacks when the database has no value. The merchant key isn't
|
||||
* secret: it travels in the payment form the payer's browser posts to PayFast.
|
||||
*/
|
||||
|
||||
const { getSetting } = require('./settingsCache');
|
||||
|
||||
const KEY_MERCHANT_ID = 'payfast_merchant_id';
|
||||
const KEY_MERCHANT_KEY = 'payfast_merchant_key';
|
||||
const KEY_PASSPHRASE = 'payfast_passphrase';
|
||||
const KEY_MODE = 'payfast_mode';
|
||||
|
||||
const HOSTS = { live: 'www.payfast.co.za', sandbox: 'sandbox.payfast.co.za' };
|
||||
|
||||
async function getPayfastConfig() {
|
||||
const [merchantId, merchantKey, passphrase, mode] = await Promise.all([
|
||||
getSetting(KEY_MERCHANT_ID, process.env.PAYFAST_MERCHANT_ID),
|
||||
getSetting(KEY_MERCHANT_KEY, process.env.PAYFAST_MERCHANT_KEY),
|
||||
getSetting(KEY_PASSPHRASE, process.env.PAYFAST_PASSPHRASE),
|
||||
getSetting(KEY_MODE, process.env.PAYFAST_MODE),
|
||||
]);
|
||||
return {
|
||||
merchantId: String(merchantId || '').trim(),
|
||||
merchantKey: String(merchantKey || '').trim(),
|
||||
passphrase: String(passphrase || ''),
|
||||
mode: mode === 'live' ? 'live' : 'sandbox',
|
||||
};
|
||||
}
|
||||
|
||||
/** PayFast's host for a mode: where payers are sent and where ITNs are confirmed. */
|
||||
const payfastHost = (mode) => HOSTS[mode === 'live' ? 'live' : 'sandbox'];
|
||||
|
||||
/** Where PayFast should post ITNs: BACKEND_URL (or the request's origin) + /api/webhooks/payfast. */
|
||||
function payfastNotifyUrl(req) {
|
||||
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
|
||||
return base ? `${base}/api/webhooks/payfast` : '';
|
||||
}
|
||||
|
||||
/** Why PayFast can't reach the notify URL (null when it can). */
|
||||
function notifyUrlProblem(url) {
|
||||
let u;
|
||||
try { u = new URL(url); } catch { return 'Set BACKEND_URL on the server so PayFast knows where to send payment notifications.'; }
|
||||
if (['localhost', '127.0.0.1', '::1', '[::1]'].includes(u.hostname)) {
|
||||
return 'PayFast can\'t reach localhost, so payments won\'t be confirmed automatically. Use a public BACKEND_URL (or a tunnel) to test notifications.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KEY_MERCHANT_ID, KEY_MERCHANT_KEY, KEY_PASSPHRASE, KEY_MODE,
|
||||
getPayfastConfig, payfastHost, payfastNotifyUrl, notifyUrlProblem,
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Shared template placeholder substitution for bulk emails/WhatsApp messages and broadcasts.
|
||||
// Async because {{payment.link}} needs to create a live Yoco checkout session per recipient —
|
||||
// Async because {{payment.link}} needs to create a live checkout session per recipient —
|
||||
// every other placeholder is a plain synchronous string replace.
|
||||
async function replacePlaceholders(str, ctx = {}) {
|
||||
if (!str) return str;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* POPIA toolkit, the data subject side: news opt-in and its consent log, unsubscribe
|
||||
* links, the opt-out filter every bulk send goes through, anonymising an account, and
|
||||
* "Download my data".
|
||||
*
|
||||
* Bulk messages come in two kinds:
|
||||
* - 'update' — about an event the person registered for (venue change, reminder). Sent
|
||||
* regardless of the news opt-in; the email links to the preferences.
|
||||
* - 'news' — News & announcements (marketing). Only to people who opted in on that
|
||||
* channel, never to an address that unsubscribed, with a one-click unsubscribe.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const CHANNELS = ['email', 'whatsapp'];
|
||||
const MESSAGE_KINDS = ['update', 'news'];
|
||||
// POPIA gives a responsible party 30 days to respond to a request.
|
||||
const REQUEST_DUE_DAYS = 30;
|
||||
|
||||
const DELETED_NAME = 'Deleted User';
|
||||
const DELETED_ATTENDEE_NAME = 'Deleted attendee';
|
||||
|
||||
function frontendUrl() {
|
||||
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function backendUrl() {
|
||||
return String(process.env.BACKEND_URL || process.env.API_URL || 'http://localhost:5000').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function normaliseKind(kind) {
|
||||
return MESSAGE_KINDS.includes(kind) ? kind : null;
|
||||
}
|
||||
|
||||
function normaliseEmail(email) {
|
||||
return String(email || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function addressFor(channel, raw) {
|
||||
if (channel === 'whatsapp') return require('./whatsapp').normalizeZAPhone(raw) || null;
|
||||
return normaliseEmail(raw) || null;
|
||||
}
|
||||
|
||||
// ─── News opt-in ──────────────────────────────────────────────────────────────
|
||||
|
||||
const NEWS_FIELD = { email: 'newsEmail', whatsapp: 'newsWhatsApp' };
|
||||
|
||||
/**
|
||||
* Set someone's news opt-in. `prefs` is { email?, whatsapp? } (booleans; missing = leave).
|
||||
* Logs one CommunicationConsent row per channel that actually changed — or every channel
|
||||
* given when `logAll` (the first answer to the opt-in question is worth recording even
|
||||
* when it keeps the current value). WhatsApp can't be switched on without a valid phone.
|
||||
*/
|
||||
async function setNewsPreferences(userId, prefs, { source, ip = null, answered = false, logAll = false } = {}, db = prisma) {
|
||||
const user = await db.user.findUnique({ where: { id: userId }, select: { id: true, phoneNumber: true, newsEmail: true, newsWhatsApp: true } });
|
||||
if (!user) return null;
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
|
||||
const data = {};
|
||||
const log = [];
|
||||
for (const channel of CHANNELS) {
|
||||
if (typeof prefs?.[channel] !== 'boolean') continue;
|
||||
let value = prefs[channel];
|
||||
if (channel === 'whatsapp' && value && !isValidZAPhone(user.phoneNumber)) value = false;
|
||||
const field = NEWS_FIELD[channel];
|
||||
if (value !== user[field]) data[field] = value;
|
||||
if (value !== user[field] || logAll) log.push({ userId, channel, granted: value, source, ip });
|
||||
}
|
||||
if (answered) data.newsAskedAt = new Date();
|
||||
|
||||
const updated = Object.keys(data).length
|
||||
? await db.user.update({ where: { id: userId }, data, select: { newsEmail: true, newsWhatsApp: true, newsAskedAt: true } })
|
||||
: await db.user.findUnique({ where: { id: userId }, select: { newsEmail: true, newsWhatsApp: true, newsAskedAt: true } });
|
||||
if (log.length) await db.communicationConsent.createMany({ data: log });
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** The consent history for one person, newest first. */
|
||||
function consentHistory(userId, db = prisma) {
|
||||
return db.communicationConsent.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, channel: true, granted: true, source: true, createdAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Unsubscribe links ────────────────────────────────────────────────────────
|
||||
// A token names an account (u) or, for people without one, an address on a channel (a, c).
|
||||
// Signed with JWT_SECRET and never expires: an unsubscribe link must keep working.
|
||||
|
||||
function sign(payload) {
|
||||
return crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`unsubscribe:${payload}`).digest('base64url').slice(0, 32);
|
||||
}
|
||||
|
||||
function unsubscribeToken(target) {
|
||||
const body = target.userId ? { u: target.userId } : { a: target.address, c: target.channel };
|
||||
const payload = Buffer.from(JSON.stringify(body)).toString('base64url');
|
||||
return `${payload}.${sign(payload)}`;
|
||||
}
|
||||
|
||||
/** { userId } or { address, channel } for a valid token, else null. */
|
||||
function readUnsubscribeToken(token) {
|
||||
const [payload, sig] = String(token || '').split('.');
|
||||
if (!payload || !sig) return null;
|
||||
const expected = sign(payload);
|
||||
if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||
try {
|
||||
const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
if (typeof body.u === 'string' && body.u) return { userId: body.u };
|
||||
if (typeof body.a === 'string' && body.a && CHANNELS.includes(body.c)) return { address: body.a, channel: body.c };
|
||||
} catch { /* fall through */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The page people land on from an unsubscribe link. */
|
||||
function unsubscribePageUrl(target) {
|
||||
return `${frontendUrl()}/unsubscribe?t=${encodeURIComponent(unsubscribeToken(target))}`;
|
||||
}
|
||||
|
||||
/** RFC 8058 one-click endpoint, for the List-Unsubscribe header (mail apps POST to it). */
|
||||
function oneClickUrl(target) {
|
||||
return `${backendUrl()}/api/privacy/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
|
||||
}
|
||||
|
||||
/** What an unsubscribe link refers to, for the landing page. */
|
||||
async function describeUnsubscribe(token) {
|
||||
const target = readUnsubscribeToken(token);
|
||||
if (!target) return null;
|
||||
if (target.userId) {
|
||||
const user = await prisma.user.findUnique({ where: { id: target.userId }, select: { email: true, phoneNumber: true, newsEmail: true, newsWhatsApp: true, isActive: true } });
|
||||
if (!user) return null;
|
||||
return {
|
||||
kind: 'account',
|
||||
email: maskEmail(user.email),
|
||||
hasPhone: !!user.phoneNumber,
|
||||
news: { email: user.newsEmail, whatsapp: user.newsWhatsApp },
|
||||
};
|
||||
}
|
||||
const optedOut = !!(await prisma.messagingOptOut.findUnique({ where: { channel_address: { channel: target.channel, address: target.address } } }));
|
||||
return {
|
||||
kind: 'address',
|
||||
channel: target.channel,
|
||||
address: target.channel === 'email' ? maskEmail(target.address) : maskPhone(target.address),
|
||||
optedOut,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from news. For an account: the given channels (default both). For an
|
||||
* address: that address on its channel. Returns the new state, or null for a bad token.
|
||||
*/
|
||||
async function unsubscribe(token, { channels = CHANNELS, source = 'unsubscribe_link', ip = null } = {}) {
|
||||
const target = readUnsubscribeToken(token);
|
||||
if (!target) return null;
|
||||
if (target.userId) {
|
||||
const prefs = {};
|
||||
for (const c of channels) if (CHANNELS.includes(c)) prefs[c] = false;
|
||||
return setNewsPreferences(target.userId, prefs, { source, ip });
|
||||
}
|
||||
await optOutAddress(target.channel, target.address, { source, ip });
|
||||
return { optedOut: true };
|
||||
}
|
||||
|
||||
/** Record that an address without an account wants no more news on a channel. */
|
||||
async function optOutAddress(channel, rawAddress, { source, ip = null } = {}, db = prisma) {
|
||||
const address = addressFor(channel, rawAddress);
|
||||
if (!address) return false;
|
||||
const existing = await db.messagingOptOut.findUnique({ where: { channel_address: { channel, address } } });
|
||||
if (existing) return true;
|
||||
await db.messagingOptOut.create({ data: { channel, address, source } });
|
||||
await db.communicationConsent.create({ data: { address, channel, granted: false, source, ip } });
|
||||
return true;
|
||||
}
|
||||
|
||||
function maskEmail(email) {
|
||||
const [local, domain] = String(email || '').split('@');
|
||||
if (!domain) return '';
|
||||
return `${local.slice(0, 2)}${'*'.repeat(Math.max(1, local.length - 2))}@${domain}`;
|
||||
}
|
||||
|
||||
function maskPhone(phone) {
|
||||
const s = String(phone || '');
|
||||
return s.length > 4 ? `${'*'.repeat(s.length - 4)}${s.slice(-4)}` : s;
|
||||
}
|
||||
|
||||
// ─── Bulk sends ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drop recipients a news message mustn't go to. `recipients` are { email } (channel
|
||||
* 'email') or { phone } (channel 'whatsapp'), optionally with userId. Removed: accounts on
|
||||
* that address without the news opt-in for the channel, and addresses that unsubscribed.
|
||||
* Each kept recipient gets `userId` filled in when the address belongs to an account, so
|
||||
* the email footer can link to the account's preferences. Update messages pass through
|
||||
* untouched (apart from the userId lookup). Returns { recipients, skipped }.
|
||||
*/
|
||||
async function filterRecipients(recipients, { channel, kind }, db = prisma) {
|
||||
const key = channel === 'whatsapp' ? 'phone' : 'email';
|
||||
const addressOf = r => addressFor(channel, r[key]);
|
||||
const addresses = [...new Set(recipients.map(addressOf).filter(Boolean))];
|
||||
if (!addresses.length) return { recipients: [], skipped: recipients.length };
|
||||
|
||||
const users = await db.user.findMany({
|
||||
where: channel === 'whatsapp'
|
||||
? { phoneNumber: { in: addresses } }
|
||||
: { OR: addresses.map(a => ({ email: { equals: a, mode: 'insensitive' } })) },
|
||||
select: { id: true, email: true, phoneNumber: true, newsEmail: true, newsWhatsApp: true },
|
||||
});
|
||||
const byAddress = new Map(users.map(u => [addressFor(channel, channel === 'whatsapp' ? u.phoneNumber : u.email), u]));
|
||||
|
||||
let optedOut = new Set();
|
||||
if (kind === 'news') {
|
||||
const rows = await db.messagingOptOut.findMany({ where: { channel, address: { in: addresses } }, select: { address: true } });
|
||||
optedOut = new Set(rows.map(r => r.address));
|
||||
}
|
||||
|
||||
const kept = [];
|
||||
for (const r of recipients) {
|
||||
const address = addressOf(r);
|
||||
const user = address ? byAddress.get(address) : null;
|
||||
if (kind === 'news') {
|
||||
if (!address || optedOut.has(address)) continue;
|
||||
if (user && !user[NEWS_FIELD[channel]]) continue;
|
||||
}
|
||||
kept.push({ ...r, userId: r.userId || user?.id || null });
|
||||
}
|
||||
return { recipients: kept, skipped: recipients.length - kept.length };
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer and headers a bulk email gets. News: unsubscribe link + List-Unsubscribe
|
||||
* (one click in Gmail/Apple Mail). Updates: why they got it, and where to manage
|
||||
* preferences. Returns { html, text, headers }.
|
||||
*/
|
||||
function bulkEmailFooter({ kind, recipient, eventTitle = '' }) {
|
||||
const prefsUrl = `${frontendUrl()}/dashboard/user/privacy`;
|
||||
const style = 'color:#94a3b8;font-size:12px;line-height:1.6;margin:24px 0 0 0;border-top:1px solid #e2e8f0;padding-top:12px';
|
||||
if (kind === 'news') {
|
||||
const target = recipient.userId ? { userId: recipient.userId } : { address: normaliseEmail(recipient.email), channel: 'email' };
|
||||
const url = unsubscribePageUrl(target);
|
||||
return {
|
||||
html: `<p style="${style}">You're receiving this because you chose to get news and announcements from us. <a href="${url}" style="color:#64748b">Unsubscribe</a>${recipient.userId ? ` · <a href="${prefsUrl}" style="color:#64748b">Manage preferences</a>` : ''}</p>`,
|
||||
text: `\n\n—\nYou're receiving this because you chose to get news and announcements from us.\nUnsubscribe: ${url}`,
|
||||
headers: {
|
||||
'List-Unsubscribe': `<${oneClickUrl(target)}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
},
|
||||
};
|
||||
}
|
||||
const why = eventTitle ? `you registered for ${eventTitle}` : 'you have a registration or account with us';
|
||||
return {
|
||||
html: `<p style="${style}">You're receiving this because ${escapeHtml(why)}. <a href="${prefsUrl}" style="color:#64748b">Manage your communication preferences</a></p>`,
|
||||
text: `\n\n—\nYou're receiving this because ${why}.\nManage your communication preferences: ${prefsUrl}`,
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** The line a bulk news WhatsApp ends with. */
|
||||
function whatsAppNewsFooter(recipient) {
|
||||
const target = recipient.userId ? { userId: recipient.userId } : { address: addressFor('whatsapp', recipient.phone), channel: 'whatsapp' };
|
||||
return `\n\n_Reply STOP to stop news messages, or unsubscribe: ${unsubscribePageUrl(target)}_`;
|
||||
}
|
||||
|
||||
/** Recognises an opt-out reply ("STOP", "stop.", "Unsubscribe"). */
|
||||
function isStopKeyword(text) {
|
||||
return /^\s*(stop|unsubscribe|stop all|opt out|optout)\s*[.!]?\s*$/i.test(String(text || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Someone replied STOP on WhatsApp: no more news on WhatsApp for that number — the
|
||||
* account's opt-in if the number belongs to one, otherwise the number itself.
|
||||
* Returns 'account' | 'address' | null (number not usable).
|
||||
*/
|
||||
async function whatsAppStop(rawPhone) {
|
||||
const phone = addressFor('whatsapp', rawPhone);
|
||||
if (!phone) return null;
|
||||
const user = await prisma.user.findFirst({ where: { phoneNumber: phone }, select: { id: true } });
|
||||
if (user) {
|
||||
await setNewsPreferences(user.id, { whatsapp: false }, { source: 'whatsapp_stop' });
|
||||
return 'account';
|
||||
}
|
||||
await optOutAddress('whatsapp', phone, { source: 'whatsapp_stop' });
|
||||
return 'address';
|
||||
}
|
||||
|
||||
// ─── Data requests ────────────────────────────────────────────────────────────
|
||||
|
||||
function dueDate(from = new Date()) {
|
||||
return new Date(from.getTime() + REQUEST_DUE_DAYS * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function createDataRequest({ user, type, details = null, selfService = false }, db = prisma) {
|
||||
const now = new Date();
|
||||
return db.dataRequest.create({
|
||||
data: {
|
||||
type,
|
||||
userId: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
details: details ? String(details).slice(0, 5000) : null,
|
||||
selfService,
|
||||
status: selfService ? 'completed' : 'open',
|
||||
closedAt: selfService ? now : null,
|
||||
dueAt: dueDate(now),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Who hears about privacy requests: the Information Officer (when set) and every admin. */
|
||||
async function privacyContacts(db = prisma) {
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const admins = await db.user.findMany({ where: { role: 'admin', isActive: true }, select: { email: true } });
|
||||
const io = getSettingSync('legal_io_email', '');
|
||||
return [...new Set([...(io && !io.endsWith('@example.com') ? [io] : []), ...admins.map(a => a.email)])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily: email the Information Officer and admins a list of open requests due within 7 days
|
||||
* or overdue. Nothing is sent when there are none.
|
||||
*/
|
||||
async function remindDueRequests(now = new Date(), db = prisma) {
|
||||
const soon = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
const due = await db.dataRequest.findMany({
|
||||
where: { status: 'open', selfService: false, dueAt: { lte: soon } },
|
||||
orderBy: { dueAt: 'asc' },
|
||||
select: { type: true, name: true, dueAt: true },
|
||||
});
|
||||
if (!due.length) return { due: 0, notified: 0 };
|
||||
|
||||
const { sendMail, buildPrivacyEmail } = require('./email');
|
||||
const to = await privacyContacts(db);
|
||||
const fmt = d => d.toLocaleDateString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
const content = buildPrivacyEmail({
|
||||
name: '',
|
||||
heading: 'Privacy requests need an answer',
|
||||
paragraphs: [
|
||||
`${due.length} privacy request${due.length === 1 ? ' is' : 's are'} due within a week or overdue. POPIA asks for an answer within 30 days.`,
|
||||
due.map(r => `${r.dueAt < now ? 'OVERDUE' : `Due ${fmt(r.dueAt)}`} — ${r.type} request from ${r.name}`).join('\n'),
|
||||
`Handle them on the Data requests page: ${frontendUrl()}/dashboard/admin/data-requests`,
|
||||
],
|
||||
});
|
||||
await Promise.allSettled(to.map(email => sendMail({ to: email, subject: 'Privacy requests need an answer', ...content })));
|
||||
return { due: due.length, notified: to.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an account can't be anonymised yet: registrations (not cancelled) for events that
|
||||
* haven't ended. The person may still need the ticket, or be owed or owe money.
|
||||
*/
|
||||
async function deletionBlockers(userId, db = prisma) {
|
||||
const regs = await db.registration.findMany({
|
||||
where: { userId, status: { not: 'cancelled' }, event: { endDate: { gte: new Date() } } },
|
||||
select: { id: true, status: true, event: { select: { id: true, title: true, endDate: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return regs.map(r => ({ registrationId: r.id, status: r.status, eventId: r.event.id, eventTitle: r.event.title, endDate: r.event.endDate }));
|
||||
}
|
||||
|
||||
// ─── Anonymising ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Keys in a provider's raw webhook payload that can hold the payer's details (PayFast:
|
||||
// name_first, name_last, email_address, cell_number; Yoco: customer metadata).
|
||||
const PERSONAL_KEY = /(^|_)(name|first|last|email|phone|cell|mobile|address|customer|surname)/i;
|
||||
|
||||
function scrubPersonal(value) {
|
||||
if (Array.isArray(value)) return value.map(scrubPersonal);
|
||||
if (value && typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
out[k] = PERSONAL_KEY.test(k) && (typeof v === 'string' || typeof v === 'number') ? '[deleted]' : scrubPersonal(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a person's personal information while keeping the records the church must keep
|
||||
* (payments, registrations, tickets, check-ins — amounts and dates) for its accounts and
|
||||
* historic reports, where they show as "Deleted User". Also covers the attendees on their
|
||||
* registrations (people they booked for) and their place on other people's bookings.
|
||||
* Irreversible. Returns counts for the audit log.
|
||||
*/
|
||||
async function anonymiseUser(userId, db = prisma) {
|
||||
const user = await db.user.findUnique({ where: { id: userId }, select: { id: true } });
|
||||
if (!user) return null;
|
||||
const { clearTrustedDevices } = require('./twoFactor');
|
||||
|
||||
const counts = await db.$transaction(async tx => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
name: DELETED_NAME,
|
||||
email: `deleted-${crypto.randomUUID()}@deleted.invalid`,
|
||||
password: '',
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
newsEmail: false,
|
||||
newsWhatsApp: false,
|
||||
},
|
||||
});
|
||||
|
||||
const attendees = await tx.attendee.findMany({ where: { OR: [{ userId }, { registration: { userId } }] }, select: { id: true } });
|
||||
const attendeeIds = attendees.map(a => a.id);
|
||||
if (attendeeIds.length) {
|
||||
await tx.attendee.updateMany({
|
||||
where: { id: { in: attendeeIds } },
|
||||
data: { name: DELETED_ATTENDEE_NAME, email: null, phone: null, dateOfBirth: null, gender: null, parentName: null, parentEmail: null, parentPhone: null },
|
||||
});
|
||||
await tx.consentRequest.updateMany({
|
||||
where: { attendeeId: { in: attendeeIds } },
|
||||
data: { medical: null, emergency: null, parentName: null, parentEmail: null, parentPhone: null, signedName: null, ip: null, device: null, medicalPurgedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// Form answers on their registrations (and for their attendees elsewhere) hold
|
||||
// whatever the form asked — delete the answers, keep that a form was filled in.
|
||||
const answers = await tx.formAnswer.deleteMany({
|
||||
where: { response: { OR: [{ registration: { userId } }, ...(attendeeIds.length ? [{ attendeeId: { in: attendeeIds } }] : [])] } },
|
||||
});
|
||||
await tx.formDraft.deleteMany({ where: { userId } });
|
||||
|
||||
// Feedback stays (it's about the event) but no longer says who gave it.
|
||||
await tx.feedbackResponse.updateMany({ where: { userId }, data: { userId: null } });
|
||||
await tx.feedbackInvite.deleteMany({ where: { userId } });
|
||||
|
||||
await tx.waitlistEntry.updateMany({ where: { userId, status: { in: ['waiting', 'offered'] } }, data: { status: 'left', offerTokenHash: null } });
|
||||
await tx.ticketTransferRequest.deleteMany({ where: { requestedById: userId } });
|
||||
await tx.ticketTransfer.updateMany({ where: { fromUserId: userId }, data: { fromHolderName: DELETED_NAME } });
|
||||
await tx.ticketTransfer.updateMany({ where: { toUserId: userId }, data: { toName: DELETED_NAME } });
|
||||
await tx.certificate.updateMany({ where: { userId }, data: { name: DELETED_NAME } });
|
||||
|
||||
await tx.payment.updateMany({ where: { userId }, data: { sponsorName: null, sponsorMessage: null } });
|
||||
const payments = await tx.payment.findMany({ where: { userId }, select: { id: true } });
|
||||
const txns = payments.length
|
||||
? await tx.providerTransaction.findMany({ where: { paymentId: { in: payments.map(p => p.id) }, raw: { not: null } }, select: { id: true, raw: true } })
|
||||
: [];
|
||||
for (const t of txns) await tx.providerTransaction.update({ where: { id: t.id }, data: { raw: scrubPersonal(t.raw) } });
|
||||
const checkouts = await tx.providerCheckout.findMany({ where: { metadata: { path: ['userId'], equals: userId } }, select: { id: true, request: true } });
|
||||
for (const c of checkouts) await tx.providerCheckout.update({ where: { id: c.id }, data: { request: scrubPersonal(c.request) } });
|
||||
|
||||
// Consent and notice records stay as proof, without the IP address they came from.
|
||||
await tx.communicationConsent.updateMany({ where: { userId }, data: { ip: null } });
|
||||
await tx.noticeAcceptance.updateMany({ where: { userId }, data: { ip: null } });
|
||||
|
||||
await tx.securityEvent.deleteMany({ where: { userId } });
|
||||
await tx.passwordReset.deleteMany({ where: { userId } });
|
||||
await tx.twoFactorChallenge.deleteMany({ where: { userId } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length };
|
||||
});
|
||||
|
||||
await clearTrustedDevices(userId);
|
||||
return counts;
|
||||
}
|
||||
|
||||
// ─── Download my data ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Everything held about a person, as plain JSON. */
|
||||
async function exportUserData(userId, db = prisma) {
|
||||
const { openJson } = require('./consent');
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true, name: true, email: true, phoneNumber: true, role: true, createdAt: true, updatedAt: true,
|
||||
notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true,
|
||||
newsEmail: true, newsWhatsApp: true, newsAskedAt: true,
|
||||
},
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices] = await Promise.all([
|
||||
db.registration.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
event: { select: { title: true, startDate: true, endDate: true } },
|
||||
registrationOptions: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
|
||||
attendees: { include: { consentRequests: { orderBy: { createdAt: 'desc' }, take: 1 } } },
|
||||
formResponses: { include: { answers: { include: { field: { select: { label: true } } } } } },
|
||||
},
|
||||
}),
|
||||
db.payment.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, include: { event: { select: { title: true } } } }),
|
||||
db.ticket.findMany({
|
||||
where: { OR: [{ userId }, { holderUserId: userId }] },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { event: { select: { title: true } }, usages: { select: { scannedAt: true, sessionId: true } } },
|
||||
}),
|
||||
db.waitlistEntry.findMany({ where: { userId }, include: { event: { select: { title: true } } } }),
|
||||
db.certificate.findMany({ where: { userId }, include: { event: { select: { title: true } } } }),
|
||||
db.feedbackResponse.findMany({ where: { userId }, include: { form: { include: { event: { select: { title: true } } } }, answers: { include: { field: { select: { label: true } } } } } }),
|
||||
consentHistory(userId, db),
|
||||
db.securityEvent.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, take: 100, select: { type: true, ip: true, device: true, createdAt: true } }),
|
||||
db.dataRequest.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { type: true, status: true, details: true, response: true, createdAt: true, closedAt: true } }),
|
||||
db.noticeAcceptance.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { version: true, source: true, createdAt: true } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
account: user,
|
||||
communication: {
|
||||
notificationChannel: user.notificationPreference,
|
||||
news: { email: user.newsEmail, whatsapp: user.newsWhatsApp, answeredAt: user.newsAskedAt },
|
||||
history: consents.map(c => ({ channel: c.channel, granted: c.granted, source: c.source, at: c.createdAt })),
|
||||
},
|
||||
privacyNoticeAccepted: notices.map(n => ({ version: n.version, how: n.source, at: n.createdAt })),
|
||||
registrations: registrations.map(r => ({
|
||||
id: r.id,
|
||||
event: r.event?.title,
|
||||
eventStart: r.event?.startDate,
|
||||
status: r.status,
|
||||
createdAt: r.createdAt,
|
||||
options: r.registrationOptions.map(o => ({ option: o.eventOption?.name, variant: o.variant?.name || null, quantity: o.quantity, price: o.priceSnapshot })),
|
||||
attendees: r.attendees.map(a => {
|
||||
const consent = a.consentRequests[0];
|
||||
return {
|
||||
name: a.name, email: a.email, phone: a.phone, dateOfBirth: a.dateOfBirth, gender: a.gender,
|
||||
parentName: a.parentName, parentEmail: a.parentEmail, parentPhone: a.parentPhone,
|
||||
parentalConsent: consent ? {
|
||||
status: consent.status, signedName: consent.signedName, respondedAt: consent.respondedAt,
|
||||
photoConsent: consent.photoConsent, medical: openJson(consent.medical), emergency: openJson(consent.emergency),
|
||||
} : null,
|
||||
};
|
||||
}),
|
||||
formAnswers: r.formResponses.flatMap(f => f.answers.map(a => ({ question: a.field?.label, answer: a.value }))),
|
||||
})),
|
||||
payments: payments.map(p => ({
|
||||
amount: p.amount, method: p.method, provider: p.provider, status: p.status, event: p.event?.title || null,
|
||||
donation: p.isDonation, sponsorName: p.sponsorName, sponsorMessage: p.sponsorMessage, at: p.createdAt,
|
||||
})),
|
||||
tickets: tickets.map(t => ({ event: t.event?.title, quantity: t.quantity, checkIns: t.usages.map(u => u.scannedAt), createdAt: t.createdAt })),
|
||||
waitlist: waitlist.map(w => ({ event: w.event?.title, quantity: w.quantity, status: w.status, joinedAt: w.createdAt })),
|
||||
certificates: certificates.map(c => ({ event: c.event?.title, name: c.name, code: c.code, issuedAt: c.issuedAt, revoked: !!c.revokedAt })),
|
||||
feedback: feedback.map(f => ({ event: f.form?.event?.title, at: f.createdAt, answers: f.answers.map(a => ({ question: a.field?.label, answer: a.value })) })),
|
||||
accountActivity: security.map(s => ({ type: s.type, ip: s.ip, device: s.device, at: s.createdAt })),
|
||||
dataRequests: requests,
|
||||
};
|
||||
}
|
||||
|
||||
/** A readable PDF of the export (the JSON is the complete copy). Resolves to a Buffer. */
|
||||
async function dataExportPdf(data) {
|
||||
const PDFDocument = require('pdfkit');
|
||||
const { getBranding } = require('./pdfDocs');
|
||||
const org = await getBranding();
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 48 });
|
||||
const chunks = [];
|
||||
doc.on('data', c => chunks.push(c));
|
||||
const done = new Promise(resolve => doc.on('end', () => resolve(Buffer.concat(chunks))));
|
||||
|
||||
const fmt = d => (d ? new Date(d).toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' }) : '—');
|
||||
const heading = text => {
|
||||
if (doc.y > doc.page.height - 120) doc.addPage();
|
||||
doc.moveDown(0.8).fillColor(org.brandColor).font('Helvetica-Bold').fontSize(13).text(text).moveDown(0.3).fillColor('#1e293b');
|
||||
};
|
||||
const line = (label, value) => doc.font('Helvetica-Bold').fontSize(10).text(`${label}: `, { continued: true }).font('Helvetica').text(value == null || value === '' ? '—' : String(value));
|
||||
const para = text => doc.font('Helvetica').fontSize(10).text(text);
|
||||
|
||||
doc.fillColor(org.brandColor).font('Helvetica-Bold').fontSize(20).text('Your personal information');
|
||||
doc.fillColor('#64748b').font('Helvetica').fontSize(10).text(`${org.name} · exported ${fmt(data.exportedAt)}`);
|
||||
doc.moveDown(0.4).text('This is a readable summary. The JSON file you can download alongside it is the complete copy.');
|
||||
doc.fillColor('#1e293b');
|
||||
|
||||
const a = data.account;
|
||||
heading('Account');
|
||||
line('Name', a.name); line('Email', a.email); line('Phone', a.phoneNumber); line('Role', a.role);
|
||||
line('Created', fmt(a.createdAt)); line('Two-factor', a.twoFactorEnabled ? `on (${a.twoFactorMethod})` : 'off');
|
||||
|
||||
heading('Communication');
|
||||
line('Notifications by', a.notificationPreference);
|
||||
line('News by email', data.communication.news.email ? 'yes' : 'no');
|
||||
line('News by WhatsApp', data.communication.news.whatsapp ? 'yes' : 'no');
|
||||
for (const h of data.communication.history) para(`${fmt(h.at)} — ${h.channel} news ${h.granted ? 'on' : 'off'} (${h.source.replace(/_/g, ' ')})`);
|
||||
|
||||
heading(`Registrations (${data.registrations.length})`);
|
||||
for (const r of data.registrations) {
|
||||
doc.font('Helvetica-Bold').fontSize(10).text(`${r.event} — ${r.status}`).font('Helvetica');
|
||||
para(`Registered ${fmt(r.createdAt)}; ${r.options.map(o => `${o.quantity} × ${o.option}${o.variant ? ` (${o.variant})` : ''}`).join(', ')}`);
|
||||
for (const p of r.attendees) para(`Attendee: ${p.name}${p.dateOfBirth ? `, born ${new Date(p.dateOfBirth).toLocaleDateString('en-ZA')}` : ''}${p.parentalConsent ? `, parental consent ${p.parentalConsent.status}` : ''}`);
|
||||
for (const f of r.formAnswers) para(`${f.question}: ${f.answer}`);
|
||||
doc.moveDown(0.3);
|
||||
}
|
||||
|
||||
heading(`Payments (${data.payments.length})`);
|
||||
for (const p of data.payments) para(`${fmt(p.at)} — R${Number(p.amount).toFixed(2)} ${p.method}${p.event ? `, ${p.event}` : ''}${p.donation ? ' (donation)' : ''}`);
|
||||
|
||||
heading(`Tickets (${data.tickets.length})`);
|
||||
for (const t of data.tickets) para(`${t.event} — ${t.quantity} place${t.quantity === 1 ? '' : 's'}, ${t.checkIns.length} check-in${t.checkIns.length === 1 ? '' : 's'}`);
|
||||
|
||||
if (data.certificates.length) {
|
||||
heading('Certificates');
|
||||
for (const c of data.certificates) para(`${c.event} — ${c.code}${c.revoked ? ' (withdrawn)' : ''}`);
|
||||
}
|
||||
if (data.feedback.length) {
|
||||
heading('Feedback you gave');
|
||||
for (const f of data.feedback) {
|
||||
doc.font('Helvetica-Bold').fontSize(10).text(f.event || 'Event').font('Helvetica');
|
||||
for (const ans of f.answers) para(`${ans.question}: ${ans.answer}`);
|
||||
}
|
||||
}
|
||||
heading('Recent account activity');
|
||||
for (const s of data.accountActivity.slice(0, 20)) para(`${fmt(s.at)} — ${s.type.replace(/_/g, ' ')}${s.device ? `, ${s.device}` : ''}`);
|
||||
|
||||
doc.end();
|
||||
return done;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CHANNELS,
|
||||
MESSAGE_KINDS,
|
||||
REQUEST_DUE_DAYS,
|
||||
DELETED_NAME,
|
||||
normaliseKind,
|
||||
setNewsPreferences,
|
||||
consentHistory,
|
||||
unsubscribeToken,
|
||||
readUnsubscribeToken,
|
||||
unsubscribePageUrl,
|
||||
describeUnsubscribe,
|
||||
unsubscribe,
|
||||
optOutAddress,
|
||||
filterRecipients,
|
||||
bulkEmailFooter,
|
||||
whatsAppNewsFooter,
|
||||
isStopKeyword,
|
||||
whatsAppStop,
|
||||
createDataRequest,
|
||||
privacyContacts,
|
||||
remindDueRequests,
|
||||
deletionBlockers,
|
||||
scrubPersonal,
|
||||
anonymiseUser,
|
||||
exportUserData,
|
||||
dataExportPdf,
|
||||
};
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* The privacy notice: the built-in text (version 0), published versions (PrivacyNotice),
|
||||
* the admin's draft, and who has accepted which version.
|
||||
*
|
||||
* A notice is a list of sections { id, title, body }. A body is plain text, one paragraph
|
||||
* per line; a line starting with "• " is a bullet. Placeholders are filled in when the
|
||||
* notice is shown, so it follows Site settings and the operator register:
|
||||
* {org} {orgEmail} {website} {ioName} {ioEmail} {medicalRetentionDays}
|
||||
* {operators} — on a line of its own: one bullet per active operator.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const PLACEHOLDERS = ['org', 'orgEmail', 'website', 'ioName', 'ioEmail', 'medicalRetentionDays', 'operators'];
|
||||
const MAX_SECTIONS = 40;
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_BODY = 20000;
|
||||
const DRAFT_KEY = 'privacy_notice_draft';
|
||||
|
||||
const DEFAULT_SECTIONS = [
|
||||
{
|
||||
id: 'summary',
|
||||
title: 'Plain English Summary',
|
||||
body: [
|
||||
"We respect your privacy — here's what you need to know:",
|
||||
'• We only collect info needed to run events: registrations, payments, tickets and check-in.',
|
||||
'• We store your info safely on South African servers.',
|
||||
"• We don't sell your data, and only share it with the services listed in section 9, which process payments and deliver messages for us.",
|
||||
'• If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.',
|
||||
'• For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.',
|
||||
'• Online payments go through Yoco or PayFast; we never see your card details.',
|
||||
'• We send tickets, updates and security alerts by email and/or WhatsApp. News and announcements are sent only if you ask for them, and you can unsubscribe at any time.',
|
||||
'• Visit statistics are only collected if you click "Allow" on the cookie notice — no third-party analytics.',
|
||||
'• On the Privacy page in your account you can download your data, ask for corrections, and delete your account.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'introduction',
|
||||
title: '1. Introduction',
|
||||
body: [
|
||||
"{org} ('we', 'us', 'our') values your privacy and is committed to protecting your personal information in accordance with the Protection of Personal Information Act (POPIA).",
|
||||
'This notice explains what data we collect, how we use it, and your rights.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'information',
|
||||
title: '2. Information We Collect',
|
||||
body: [
|
||||
'We collect the following information when you interact with our platform:',
|
||||
'• Account information: name, email address, phone number, notification preference and password (stored only as an encrypted hash). If you use two-factor authentication, we keep which method you use; codes are short-lived and never stored in readable form.',
|
||||
'• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.',
|
||||
'• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.',
|
||||
'• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).',
|
||||
'• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.',
|
||||
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
|
||||
'• Your choices: whether you want news and announcements, which version of this notice you accepted, and when you made each choice.',
|
||||
'• Communication data: emails or messages exchanged with our team.',
|
||||
'• Visit statistics: only if you allow them on the cookie notice (see section 11).',
|
||||
'We do not collect or store credit card numbers or bank details.',
|
||||
'Accounts may be created by an administrator, or when someone registers you or transfers a ticket to you, with a mobile number or email address only. You can activate such an account at any time.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'others',
|
||||
title: '3. People You Register or Transfer Tickets To',
|
||||
body: [
|
||||
'You can register other people (for example family members) and give each one their own ticket, or pass your ticket on to someone else.',
|
||||
'• You type their name and, optionally, their email address or phone number. There is no search or list of existing users to choose from, and we never tell you whether the details you enter belong to an account.',
|
||||
'• We use those details only to send that person their ticket, their attendee form, a parental consent request (youth events), or — for a transfer — a link to accept the ticket. A transfer only happens once they accept it; they can also decline.',
|
||||
"• If the email or phone number matches an existing account, the ticket also appears in that person's own account. Otherwise an account may be created for them, which they can activate if they wish.",
|
||||
"• Some events ask for each person's date of birth or gender (for example, to allocate rooms at a camp).",
|
||||
'By entering someone else\'s details, you confirm that you are allowed to share them with us for this purpose (for a child, that you are their parent or guardian or have their permission).',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'use',
|
||||
title: '4. How We Use Your Information',
|
||||
body: [
|
||||
'We use your information to:',
|
||||
'• Process event registrations, payments, discounts and refunds.',
|
||||
'• Send updates about events you registered for — confirmations, reminders and changes — by email and/or WhatsApp (based on your preference).',
|
||||
'• Send news and announcements about upcoming events, only if you asked for them. Every such email has an unsubscribe link, and you can reply STOP on WhatsApp.',
|
||||
'• Send security notifications for new logins, password changes, and account actions on your account.',
|
||||
'• Deliver your event tickets via email attachment and/or WhatsApp PDF (based on your preference).',
|
||||
'• Check tickets in at the door, including with scanners that keep working without an internet connection.',
|
||||
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
|
||||
'• Send you an optional feedback form after an event, with one reminder.',
|
||||
'• Manage user accounts and event attendance records, and give support when you ask for help.',
|
||||
'• Improve our events and website (using anonymous statistics, and only with your permission).',
|
||||
'• Comply with legal obligations.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'whatsapp',
|
||||
title: '5. WhatsApp Communications',
|
||||
body: [
|
||||
"If you choose 'WhatsApp' or 'Both' as your notification preference, we will use your South African mobile number to send you messages via WhatsApp. These messages may include:",
|
||||
'• Event registration confirmations and updates.',
|
||||
'• Payment receipts.',
|
||||
'• Your event tickets (as a PDF document).',
|
||||
'• Security alerts such as new login notifications, password changes, and account closure confirmations.',
|
||||
'• News and announcements, only if you asked for them on WhatsApp. Reply STOP to any of these to stop them.',
|
||||
'WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages.',
|
||||
'Security-critical messages (login alerts, password resets, account closures) are always sent via email regardless of your preference, and additionally sent via WhatsApp if your preference includes it.',
|
||||
"You may change your notification preference at any time from the Profile & Security page in your account. Selecting 'Email only' will stop WhatsApp messages from being sent to you.",
|
||||
'For accounts registered without a valid email address (e.g. walk-in or at-the-door registrations), WhatsApp will be used as the primary delivery channel for payment confirmations and ticket delivery, regardless of notification preference, since email is unavailable.',
|
||||
'If your account requires activation and no email address is on file, the activation link will be sent to your WhatsApp number instead.',
|
||||
"WhatsApp is a product of Meta Platforms, Inc. By receiving WhatsApp messages from us, you are subject to WhatsApp's own Terms of Service and Privacy Policy.",
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
title: '6. Where We Store Your Data',
|
||||
body: [
|
||||
'All data is securely stored on cloud servers hosted in South Africa.',
|
||||
'We use appropriate technical and organisational measures to protect your information from unauthorised access, alteration, or disclosure.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'retention',
|
||||
title: '7. Data Retention',
|
||||
body: [
|
||||
'We retain your personal data for up to 5 years after your last event registration, or until you ask us to delete your account — whichever comes first.',
|
||||
'Medical and emergency-contact details from parental consent forms are deleted {medicalRetentionDays} days after the event ends. The record of who gave consent, when, and to which wording is kept.',
|
||||
'Detailed visit statistics are combined into anonymous daily totals after 90 days.',
|
||||
"Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
|
||||
'Records of support access to accounts (see section 9), of who exported personal information, and other audit records are kept for accountability.',
|
||||
'You can ask for your account to be deleted from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
|
||||
'Backups of our database still contain deleted information until they are replaced, which happens automatically within a few weeks.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'children',
|
||||
title: '8. Children, Parental Consent and Health Information',
|
||||
body: [
|
||||
'Users under 18 may only create an account or register for events with the consent and supervision of a parent or guardian.',
|
||||
'We do not knowingly collect or process personal information of minors without such consent.',
|
||||
"For youth events, each attendee's date of birth is required. For anyone under 18, the parent or guardian named at registration is sent a consent request by email and WhatsApp, where they review the event details and give or decline consent. Their typed name, the time, the wording agreed to, and the IP address and device type used are recorded as proof of consent. A young person can't be checked in until consent is given.",
|
||||
"The consent form may ask for medical information (such as allergies, medication and conditions) and an emergency contact. This is special personal information under POPIA. We collect it only with the parent's or guardian's consent, for the young person's safety at the event. It is stored encrypted and shown only to supervisors who need it, and each time the medical list is viewed this is recorded. It is deleted after the event as described in section 7.",
|
||||
'A parent or guardian may also say whether photos of the young person may be used.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'sharing',
|
||||
title: '9. Who Can See Your Information',
|
||||
body: [
|
||||
'We do not sell or rent your personal information. Outside our organisation, it is shared only with these service providers ("operators"), each only for its purpose:',
|
||||
'{operators}',
|
||||
'• Anyone we are required to share it with under South African law.',
|
||||
'Within our organisation, people see only what they need:',
|
||||
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
|
||||
'• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details are shown to supervisors only.',
|
||||
'• Supervisors and administrators manage registrations, payments and communication for events. Every export of personal information (reports, attendance registers, lists) is recorded.',
|
||||
'• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.',
|
||||
'• Sponsors never see who was helped by a sponsorship; the public only sees how many spots were sponsored.',
|
||||
'• Nobody else can see whether you have an account.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'rights',
|
||||
title: '10. Your Rights (under POPIA)',
|
||||
body: [
|
||||
'Under POPIA, you have the right to:',
|
||||
'• Access your personal information.',
|
||||
'• Request correction or deletion of your data.',
|
||||
'• Withdraw consent for processing, including for news and announcements.',
|
||||
'• Object to direct marketing.',
|
||||
'• Lodge a complaint with the Information Regulator of South Africa.',
|
||||
'The Privacy page in your account lets you download your data, ask for a correction, delete your account and choose which messages you receive. We answer requests within 30 days.',
|
||||
'To contact the Information Regulator:',
|
||||
'• Website: www.inforeg.org.za',
|
||||
'• Email: enquiries@inforeg.org.za',
|
||||
'To exercise your rights directly with us, contact our Information Officer using the details in section 14 below.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'cookies',
|
||||
title: '11. Cookie Policy (Cookies and Browser Storage)',
|
||||
body: [
|
||||
"Our own site does not set tracking cookies. It keeps a few small items in your browser's storage (localStorage, and on staff scanners IndexedDB and an offline page cache). These work much like cookies, so this policy covers them. We do not use third-party analytics, advertising or tracking pixels.",
|
||||
"Strictly necessary — always used, because the site can't work without them:",
|
||||
'• hope_events_token — keeps you signed in.',
|
||||
'• hope_events_user_cache — your last loaded profile, so pages keep working if your connection drops.',
|
||||
'• hope_events_device — only if you tick "Trust this device" when entering a two-factor code, so you aren\'t asked for a code every time.',
|
||||
'• hope_events_cookie_consent — remembers your choice on the cookie notice.',
|
||||
'• banner_dismissed_… — remembers notices you have closed.',
|
||||
'Staff and administrators only:',
|
||||
'• hope_events_scan_device, hope_events_offline_lock and the "hope-events-offline-scan" database — on a staff member\'s scanner in offline mode: the device\'s id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.',
|
||||
'• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.',
|
||||
'Optional — only if you click "Allow" on the cookie notice:',
|
||||
'• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose "No thanks", nothing is counted and no visit id is created.',
|
||||
'Third parties:',
|
||||
"• Event pages with a location show an embedded Google Maps map. Google may set its own cookies when the map loads (see Google's privacy policy).",
|
||||
"• Payments open the payment provider's secure checkout page (Yoco or PayFast), which uses its own cookies on the provider's site.",
|
||||
'You can change your cookie choice at any time with "Cookie settings" at the bottom of every page, or with the button above. You can also clear your browser\'s storage in your browser settings; this signs you out.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
title: '12. Security',
|
||||
body: [
|
||||
'Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details are additionally encrypted in our database.',
|
||||
'Passwords are encrypted and cannot be viewed by anyone, including administrators.',
|
||||
'Two-factor authentication is available to everyone and is required for supervisors and administrators.',
|
||||
'Links we send for consent, feedback, ticket transfers and unsubscribing are personal. Only a secure fingerprint of most links is stored, not the link itself.',
|
||||
'In the event of a data breach that poses a risk to your rights, we will notify you and the Information Regulator of South Africa as required under POPIA section 22, without unreasonable delay.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
title: '13. Changes to This Notice',
|
||||
body: [
|
||||
'We may update this notice from time to time. Each update is published as a new version with its date, and earlier versions stay available on this page.',
|
||||
'If a change is significant, we ask you to accept the new version the next time you log in.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'officer',
|
||||
title: '14. Information Officer',
|
||||
body: [
|
||||
'The Information Officer responsible for overseeing compliance with POPIA on behalf of {org} is:',
|
||||
'• Name: {ioName}',
|
||||
'• Email: {ioEmail}',
|
||||
'You may contact the Information Officer for any POPIA-related requests, including access to your personal data, requests for correction or deletion, and formal complaints.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
title: '15. Contact',
|
||||
body: [
|
||||
'For general privacy-related questions or requests, you can also contact us at:',
|
||||
'📧 {orgEmail}',
|
||||
].join('\n'),
|
||||
},
|
||||
];
|
||||
|
||||
function defaultSections() {
|
||||
return DEFAULT_SECTIONS.map(s => ({ ...s }));
|
||||
}
|
||||
|
||||
function slugify(text) {
|
||||
return String(text || '').toLowerCase().replace(/^\d+\.\s*/, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'section';
|
||||
}
|
||||
|
||||
/** Checks and tidies sections from the editor. Throws a message fit for the admin. */
|
||||
function cleanSections(input) {
|
||||
if (!Array.isArray(input) || input.length === 0) throw new Error('The notice needs at least one section.');
|
||||
if (input.length > MAX_SECTIONS) throw new Error(`At most ${MAX_SECTIONS} sections.`);
|
||||
const seen = new Set();
|
||||
return input.map((s, i) => {
|
||||
const title = String(s?.title || '').trim();
|
||||
const body = String(s?.body || '').replace(/\r\n/g, '\n').trim();
|
||||
if (!title) throw new Error(`Section ${i + 1} needs a title.`);
|
||||
if (title.length > MAX_TITLE) throw new Error(`The title of "${title.slice(0, 40)}…" is too long.`);
|
||||
if (body.length > MAX_BODY) throw new Error(`"${title}" is too long.`);
|
||||
let id = slugify(s?.id || title);
|
||||
while (seen.has(id)) id = `${id}-${i + 1}`;
|
||||
seen.add(id);
|
||||
return { id, title, body };
|
||||
});
|
||||
}
|
||||
|
||||
/** Settings and operators the placeholders are filled from. */
|
||||
async function noticeContext(db = prisma) {
|
||||
const { getSetting } = require('./settingsCache');
|
||||
const [org, orgEmail, website, ioName, ioEmail, medicalDays, operators] = await Promise.all([
|
||||
getSetting('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
getSetting('org_email', ''),
|
||||
getSetting('legal_website_url', ''),
|
||||
getSetting('legal_io_name', ''),
|
||||
getSetting('legal_io_email', ''),
|
||||
getSetting('consent_medical_retention_days', ''),
|
||||
db.operator.findMany({ where: { active: true }, orderBy: [{ order: 'asc' }, { name: 'asc' }] }),
|
||||
]);
|
||||
// Unset legal fields show an obviously-unfinished placeholder, never someone's real details.
|
||||
return {
|
||||
org,
|
||||
orgEmail: orgEmail || 'privacy@example.com',
|
||||
website: website || 'example.com',
|
||||
ioName: ioName || '[not yet configured]',
|
||||
ioEmail: ioEmail || 'io@example.com',
|
||||
medicalRetentionDays: String(parseInt(medicalDays, 10) || 30),
|
||||
operators,
|
||||
};
|
||||
}
|
||||
|
||||
function operatorLine(o) {
|
||||
return `• ${o.name}${o.location ? ` (${o.location})` : ''} — ${o.purpose}. ${o.dataShared ? `Receives: ${o.dataShared}.` : ''}`.trim();
|
||||
}
|
||||
|
||||
/** Sections with their placeholders filled in. */
|
||||
function renderSections(sections, ctx) {
|
||||
return sections.map(s => {
|
||||
const body = String(s.body || '')
|
||||
.split('\n')
|
||||
.flatMap(line => (line.trim() === '{operators}' ? (ctx.operators.length ? ctx.operators.map(operatorLine) : ['• (No service providers listed.)']) : [line]))
|
||||
.map(line => line.replace(/\{(\w+)\}/g, (m, key) => (key !== 'operators' && PLACEHOLDERS.includes(key) ? ctx[key] : m)))
|
||||
.join('\n');
|
||||
return { id: s.id, title: s.title, body };
|
||||
});
|
||||
}
|
||||
|
||||
async function latestNotice(db = prisma) {
|
||||
return db.privacyNotice.findFirst({ orderBy: { version: 'desc' } });
|
||||
}
|
||||
|
||||
/** The newest version people must have accepted, or null when none asks for it. */
|
||||
async function requiredVersion(db = prisma) {
|
||||
const row = await db.privacyNotice.findFirst({ where: { requiresAcceptance: true }, orderBy: { version: 'desc' }, select: { version: true } });
|
||||
return row ? row.version : null;
|
||||
}
|
||||
|
||||
/** Whether someone must (re-)accept the notice before carrying on. */
|
||||
async function noticePromptPending(user, db = prisma) {
|
||||
const required = await requiredVersion(db);
|
||||
if (required == null) return false;
|
||||
return (user.noticeVersionAccepted ?? -1) < required;
|
||||
}
|
||||
|
||||
/**
|
||||
* A notice for the public page: the given version (or the current one), rendered, with
|
||||
* the list of versions. Version 0 is the built-in notice.
|
||||
*/
|
||||
async function publicNotice(version, db = prisma) {
|
||||
const versions = await db.privacyNotice.findMany({
|
||||
orderBy: { version: 'desc' },
|
||||
select: { version: true, publishedAt: true, changeSummary: true, requiresAcceptance: true },
|
||||
});
|
||||
const ctx = await noticeContext(db);
|
||||
const wanted = version == null ? versions[0]?.version ?? 0 : version;
|
||||
|
||||
if (wanted === 0) {
|
||||
if (version != null && versions.length) return null; // the built-in text isn't a past version
|
||||
const { getSetting } = require('./settingsCache');
|
||||
return {
|
||||
version: 0,
|
||||
builtIn: true,
|
||||
current: true,
|
||||
publishedAt: null,
|
||||
effectiveDate: (await getSetting('legal_effective_date', '')) || '[not yet set]',
|
||||
changeSummary: null,
|
||||
sections: renderSections(DEFAULT_SECTIONS, ctx),
|
||||
versions,
|
||||
};
|
||||
}
|
||||
const notice = await db.privacyNotice.findUnique({ where: { version: wanted } });
|
||||
if (!notice) return null;
|
||||
return {
|
||||
version: notice.version,
|
||||
builtIn: false,
|
||||
current: notice.version === versions[0]?.version,
|
||||
publishedAt: notice.publishedAt,
|
||||
effectiveDate: notice.publishedAt.toLocaleDateString('en-ZA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Africa/Johannesburg' }),
|
||||
changeSummary: notice.changeSummary,
|
||||
sections: renderSections(notice.sections, ctx),
|
||||
versions,
|
||||
};
|
||||
}
|
||||
|
||||
/** The version a new acceptance is for: the latest published, or 0 (built-in). */
|
||||
async function currentVersion(db = prisma) {
|
||||
return (await latestNotice(db))?.version ?? 0;
|
||||
}
|
||||
|
||||
/** Record that someone accepted the current notice. */
|
||||
async function acceptNotice(userId, { source, ip = null } = {}, db = prisma) {
|
||||
const version = await currentVersion(db);
|
||||
await db.user.update({ where: { id: userId }, data: { noticeVersionAccepted: version } });
|
||||
await db.noticeAcceptance.create({ data: { userId, version, source, ip } });
|
||||
return version;
|
||||
}
|
||||
|
||||
// ─── Draft (admin) ────────────────────────────────────────────────────────────
|
||||
|
||||
/** The draft being edited, or — when there is none — the current notice's sections. */
|
||||
async function getDraft(db = prisma) {
|
||||
const row = await db.appSetting.findUnique({ where: { key: DRAFT_KEY } });
|
||||
if (row?.value) {
|
||||
try {
|
||||
const draft = JSON.parse(row.value);
|
||||
if (Array.isArray(draft.sections)) return { sections: draft.sections, savedAt: draft.savedAt || null, isDraft: true };
|
||||
} catch { /* fall through to the current notice */ }
|
||||
}
|
||||
const latest = await latestNotice(db);
|
||||
return { sections: latest ? latest.sections : defaultSections(), savedAt: null, isDraft: false };
|
||||
}
|
||||
|
||||
async function saveDraft(sections, db = prisma) {
|
||||
const clean = cleanSections(sections);
|
||||
const value = JSON.stringify({ sections: clean, savedAt: new Date().toISOString() });
|
||||
await db.appSetting.upsert({ where: { key: DRAFT_KEY }, update: { value }, create: { key: DRAFT_KEY, value } });
|
||||
return clean;
|
||||
}
|
||||
|
||||
async function discardDraft(db = prisma) {
|
||||
await db.appSetting.deleteMany({ where: { key: DRAFT_KEY } });
|
||||
}
|
||||
|
||||
/** Publish the given sections as the next version, and clear the draft. */
|
||||
async function publish({ sections, changeSummary, requiresAcceptance, publishedById }, db = prisma) {
|
||||
const clean = cleanSections(sections);
|
||||
const notice = await db.$transaction(async tx => {
|
||||
const latest = await tx.privacyNotice.findFirst({ orderBy: { version: 'desc' }, select: { version: true } });
|
||||
return tx.privacyNotice.create({
|
||||
data: {
|
||||
version: (latest?.version ?? 0) + 1,
|
||||
sections: clean,
|
||||
changeSummary: changeSummary ? String(changeSummary).trim().slice(0, 2000) : null,
|
||||
requiresAcceptance: !!requiresAcceptance,
|
||||
publishedById: publishedById || null,
|
||||
},
|
||||
});
|
||||
});
|
||||
await discardDraft(db);
|
||||
return notice;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PLACEHOLDERS,
|
||||
defaultSections,
|
||||
cleanSections,
|
||||
noticeContext,
|
||||
renderSections,
|
||||
operatorLine,
|
||||
requiredVersion,
|
||||
noticePromptPending,
|
||||
publicNotice,
|
||||
currentVersion,
|
||||
acceptNotice,
|
||||
getDraft,
|
||||
saveDraft,
|
||||
discardDraft,
|
||||
publish,
|
||||
};
|
||||
@@ -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 ticket for other sessions (picked, or ticked on an all-sessions ticket) has nothing left today.
|
||||
const otherSession = !!(session ? !sessionsUtil.lineCovers(t.registrationOption, session.id) : t.registrationOption.sessionId);
|
||||
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 && !sessionsUtil.lineCovers(ticket.registrationOption, 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 (all sessions, or this one ticked) 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, sessionIds: { isEmpty: true } }, { sessionId: null, sessionIds: { has: sessionId } }, { 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,578 @@
|
||||
/**
|
||||
* 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 (and places held for waitlist offers); a pick option's stock
|
||||
* limit is places per session. The waitlist for a pick option is per session.
|
||||
* `all` options on such an event also let people tick the sessions they'll come to
|
||||
* (RegistrationOption.sessionIds): one line, one ticket and one price, but the ticket
|
||||
* scans, counts towards capacity and gets reminders for those sessions only. No
|
||||
* sessions ticked = every session (the whole-course rules).
|
||||
* 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 people choose sessions on this event (attendance mode "pick sessions"). */
|
||||
function choosesSessions(event) {
|
||||
return event?.scheduleType === 'sessions' && event?.attendanceMode === 'pick';
|
||||
}
|
||||
|
||||
/** 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); an all-sessions option on a pick-sessions event
|
||||
* keeps its sessionIds (the sessions ticked, empty = every session) on one line. 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 = [];
|
||||
const bookable = (option, id) => {
|
||||
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.`);
|
||||
};
|
||||
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))] : [];
|
||||
const { sessionIds, ...rest } = line;
|
||||
void sessionIds;
|
||||
if (!pick) {
|
||||
if (ids.length && !choosesSessions(event)) throw sessionError(`"${option?.name || 'This ticket'}" covers every session — no sessions to choose.`);
|
||||
for (const id of ids) bookable(option, id);
|
||||
out.push({ ...rest, sessionId: null, sessionIds: ids });
|
||||
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) {
|
||||
bookable(option, id);
|
||||
out.push({ ...rest, sessionId: id });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sessions a line attends: its picked session, the sessions ticked on an
|
||||
* all-sessions ticket, or null for every session.
|
||||
* @param {{ sessionId?: string|null, sessionIds?: string[] }} line
|
||||
* @returns {string[]|null}
|
||||
*/
|
||||
function lineSessions(line) {
|
||||
if (line?.sessionId) return [line.sessionId];
|
||||
return line?.sessionIds?.length ? line.sessionIds : null;
|
||||
}
|
||||
|
||||
/** Whether a ticket's line covers `sessionId` (picked session, ticked sessions, or every session). */
|
||||
function lineCovers(line, sessionId) {
|
||||
const ids = lineSessions(line);
|
||||
return !ids || ids.includes(sessionId);
|
||||
}
|
||||
|
||||
/** Merges the ticked sessions of two all-sessions lines (empty = every session wins). */
|
||||
function mergeSessionIds(a, b) {
|
||||
if (!a?.length || !b?.length) return [];
|
||||
return [...new Set([...a, ...b])];
|
||||
}
|
||||
|
||||
/**
|
||||
* People booked per session: picked places on that session plus whole-course main
|
||||
* tickets (which attend every session). Cancelled registrations don't count; places
|
||||
* held for a live waitlist offer do (except `excludeEntryId`, the offer being claimed).
|
||||
* @returns {Promise<Map<string, number>>}
|
||||
*/
|
||||
async function sessionBookings(eventId, db = prisma, { excludeEntryId = null } = {}) {
|
||||
const [booked, held] = await Promise.all([
|
||||
db.registrationOption.findMany({
|
||||
where: { registration: { eventId, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
|
||||
select: { quantity: true, sessionId: true, sessionIds: true },
|
||||
}),
|
||||
db.waitlistEntry.findMany({
|
||||
where: {
|
||||
eventId, status: 'offered', offerExpiresAt: { gt: new Date() }, eventOption: { isMainTicket: true },
|
||||
...(excludeEntryId ? { id: { not: excludeEntryId } } : {}),
|
||||
},
|
||||
select: { offeredQuantity: true, sessionId: true },
|
||||
}),
|
||||
]);
|
||||
const lines = [...booked, ...held.map(h => ({ quantity: h.offeredQuantity, sessionId: h.sessionId }))];
|
||||
const sessions = await db.eventSession.findMany({ where: { eventId, cancelledAt: null }, select: { id: true } });
|
||||
const out = new Map(sessions.map(s => [s.id, 0]));
|
||||
for (const l of lines) {
|
||||
for (const id of lineSessions(l) || out.keys()) if (out.has(id)) out.set(id, out.get(id) + (l.quantity || 0));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session places free for a main-ticket line: its own session (picked) or, for a
|
||||
* whole-course ticket, the fullest session with a capacity. Pure.
|
||||
* @param {object[]} sessions - the event's sessions (with capacity)
|
||||
* @param {Map<string, number>} booked - from sessionBookings
|
||||
* @param {string|null} sessionId - the picked session, or null for whole-course
|
||||
* @returns {number|null} null when no session limits it
|
||||
*/
|
||||
function sessionPlacesFree(sessions, booked, sessionId = null) {
|
||||
const limited = activeSessions(sessions).filter(s => s.capacity > 0 && (!sessionId || s.id === sessionId));
|
||||
if (limited.length === 0) return null;
|
||||
return Math.max(0, Math.min(...limited.map(s => s.capacity - (booked.get(s.id) || 0))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks requested lines fit each session's capacity. Whole-course main tickets count
|
||||
* against every session; picked main tickets against their own.
|
||||
* @param {{ excludeEntryId?: string }} [opts] - the waitlist offer being claimed
|
||||
*/
|
||||
async function assertSessionCapacity(event, lines, db = prisma, { excludeEntryId = null } = {}) {
|
||||
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();
|
||||
for (const s of limited) wanted.set(s.id, 0);
|
||||
for (const l of lines) {
|
||||
if (!mains.has(l.eventOptionId)) continue;
|
||||
for (const id of lineSessions(l) || wanted.keys()) if (wanted.has(id)) wanted.set(id, wanted.get(id) + (Number(l.quantity) || 0));
|
||||
}
|
||||
if ([...wanted.values()].every(n => n === 0)) return;
|
||||
const booked = await sessionBookings(event.id, db, { excludeEntryId });
|
||||
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, chosenIds = null) {
|
||||
if (event?.scheduleType !== 'sessions') return null;
|
||||
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
|
||||
// An all-sessions ticket with sessions ticked: those sessions.
|
||||
const chosen = chosenIds?.length ? activeSessions(event.sessions).filter(s => chosenIds.includes(s.id)) : [];
|
||||
if (!session && chosen.length === 1) session = chosen[0];
|
||||
else if (!session && chosen.length > 1) {
|
||||
return style === 'long'
|
||||
? `${chosen.map(s => fmt(s.startsAt, { weekday: 'short', day: 'numeric', month: 'short' })).join(', ')} (${chosen.length} sessions — one ticket for all)`
|
||||
: `${chosen.length} sessions · ${chosen.map(s => fmt(s.startsAt, { day: '2-digit', month: 'short' })).join(', ')}`;
|
||||
}
|
||||
// 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); an all-sessions ticket with sessions ticked
|
||||
* (`chosenIds`) only on those.
|
||||
*/
|
||||
function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null, now = new Date(), chosenIds = null) {
|
||||
if (!pinnedSessionId && chosenIds?.length) {
|
||||
const found = scanSession(event, sessions, requestedId, now);
|
||||
if (!found.session || chosenIds.includes(found.session.id)) return found;
|
||||
// Another of their sessions may be open today (a day with more than one session).
|
||||
const own = requestedId ? null : currentSession(activeSessions(sessions).filter(s => chosenIds.includes(s.id)), now, event.checkInWindow || 'whole_day');
|
||||
if (own) return { session: own, error: null };
|
||||
return { session: null, error: `This ticket isn't booked for ${sessionLabel(found.session, sessions)} (${dayKey(found.session.startsAt)})` };
|
||||
}
|
||||
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, sessionIds }.
|
||||
* An all-sessions ticket with sessions ticked is its own group, limited to those sessions.
|
||||
* @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: ro.sessionIds?.length ? [...ro.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, choosesSessions, isPickOption, expandPickLines, lineSessions, lineCovers, mergeSessionIds, sessionBookings, sessionPlacesFree, assertSessionCapacity,
|
||||
lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
|
||||
syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession,
|
||||
};
|
||||
@@ -11,7 +11,7 @@ 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', 'yoco_secret_key', 'yoco_webhook_secret']);
|
||||
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass', 'yoco_secret_key', 'yoco_webhook_secret', 'payfast_passphrase']);
|
||||
|
||||
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' } } },
|
||||
@@ -35,15 +37,17 @@ async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId
|
||||
|
||||
/**
|
||||
* Places still free on a line: the tighter of the option and variant limits.
|
||||
* @param {{ sessionId?: string|null, excludeEntryId?: string, db?: object }} [opts] -
|
||||
* sessionId: a "pick sessions" line (the limits are places per session)
|
||||
* @returns {Promise<number|null>} null when unlimited
|
||||
*/
|
||||
async function remainingFor(option, variant, opts = {}) {
|
||||
async function remainingFor(option, variant, { sessionId = null, ...opts } = {}) {
|
||||
const limits = [];
|
||||
if (option?.stockLimit > 0) {
|
||||
limits.push(option.stockLimit - await takenCount({ eventOptionId: option.id }, opts));
|
||||
limits.push(option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, opts));
|
||||
}
|
||||
if (variant?.stockLimit > 0) {
|
||||
limits.push(variant.stockLimit - await takenCount({ eventOptionId: option.id, variantId: variant.id }, opts));
|
||||
limits.push(variant.stockLimit - await takenCount({ eventOptionId: option.id, variantId: variant.id, sessionId }, opts));
|
||||
}
|
||||
if (limits.length === 0) return null;
|
||||
return Math.max(0, Math.min(...limits));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
* offered what's free; otherwise they're skipped for the next person who fits.
|
||||
* - Claiming = registering with the link (createRegistration's waitlistToken). Early-bird
|
||||
* and coupon rules apply as usual. An unclaimed offer expires and moves on.
|
||||
* - "Pick sessions" options are waited for per session (entry.sessionId): free places are
|
||||
* that session's, an offer never runs past the session's start, and nobody is offered a
|
||||
* session that has started or was cancelled. Main tickets also need room in the
|
||||
* session(s) they cover when sessions have a capacity (utils/sessions.js).
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { remainingFor } = require('./stock');
|
||||
const { activeSessions, isPickOption, sessionBookings, sessionPlacesFree, sessionLabel, wholeCourseClosesAt } = require('./sessions');
|
||||
|
||||
const ACTIVE = ['waiting', 'offered'];
|
||||
|
||||
@@ -27,6 +32,42 @@ function waitlistError(message, statusCode = 400) {
|
||||
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`waitlist:${token}`).digest('hex');
|
||||
|
||||
/**
|
||||
* Places free on a waitlist line: option/variant stock (per session for a picked line)
|
||||
* and, for main tickets on events with session capacities, room in the session(s).
|
||||
* @param {object} event - with sessions
|
||||
* @returns {Promise<number|null>} null when unlimited
|
||||
*/
|
||||
async function placesFree(event, option, variant, sessionId = null, { db = prisma, excludeEntryId = null } = {}) {
|
||||
const limits = [await remainingFor(option, variant, { sessionId, db, excludeEntryId })];
|
||||
if (event.scheduleType === 'sessions' && option.isMainTicket && (event.sessions || []).some(s => s.capacity > 0)) {
|
||||
limits.push(sessionPlacesFree(event.sessions, await sessionBookings(event.id, db, { excludeEntryId }), sessionId));
|
||||
}
|
||||
const known = limits.filter(n => n !== null);
|
||||
return known.length ? Math.min(...known) : null;
|
||||
}
|
||||
|
||||
/** Whether a waitlist line can still be offered at `now` (its session hasn't started, etc.). */
|
||||
function lineOpen(event, sessionId, now = new Date()) {
|
||||
if (!sessionId) return event.scheduleType !== 'sessions' || now < wholeCourseClosesAt(event, event.sessions);
|
||||
const session = activeSessions(event.sessions).find(s => s.id === sessionId);
|
||||
return !!session && new Date(session.startsAt) > now;
|
||||
}
|
||||
|
||||
/** When an offer made now lapses: the event's offer hours, but never after its session starts. */
|
||||
function offerExpiry(hours, session = null, now = new Date()) {
|
||||
const expires = new Date(now.getTime() + (hours || 24) * 60 * 60 * 1000);
|
||||
return session && new Date(session.startsAt) < expires ? new Date(session.startsAt) : expires;
|
||||
}
|
||||
|
||||
/** "Drop-in (Large)" or "Drop-in — Class 2, Wed 4 Nov" for messages. */
|
||||
function lineName(entry, sessions = entry.event?.sessions) {
|
||||
const base = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
|
||||
if (!entry.session) return base;
|
||||
const when = new Date(entry.session.startsAt).toLocaleDateString('en-GB', { timeZone: process.env.APP_TIMEZONE || 'Africa/Johannesburg', weekday: 'short', day: 'numeric', month: 'short' });
|
||||
return `${base} — ${sessionLabel(entry.session, sessions || [entry.session])}, ${when}`;
|
||||
}
|
||||
|
||||
function claimLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/waitlist/claim/${token}`;
|
||||
@@ -63,7 +104,7 @@ async function notifyOffer(entry, token) {
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const user = entry.user;
|
||||
const link = claimLink(token);
|
||||
const item = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
|
||||
const item = lineName(entry);
|
||||
const until = new Date(entry.offerExpiresAt).toLocaleString('en-GB', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
const partialNote = entry.offeredQuantity < entry.quantity
|
||||
? ` You asked for ${entry.quantity}; ${entry.offeredQuantity} came free, and you can take those now.` : '';
|
||||
@@ -86,10 +127,10 @@ async function notifyOffer(entry, token) {
|
||||
await Promise.allSettled(sends);
|
||||
}
|
||||
|
||||
const OFFER_INCLUDE = { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
|
||||
const OFFER_INCLUDE = { event: { include: { sessions: true } }, eventOption: true, variant: true, session: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
|
||||
|
||||
/** Puts an entry on offer (inside the caller's transaction); returns the raw token. */
|
||||
async function writeOffer(db, entry, quantity, hours) {
|
||||
/** Puts an entry on offer until `expiresAt` (inside the caller's transaction); returns the raw token. */
|
||||
async function writeOffer(db, entry, quantity, expiresAt) {
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
await db.waitlistEntry.update({
|
||||
where: { id: entry.id },
|
||||
@@ -98,7 +139,7 @@ async function writeOffer(db, entry, quantity, hours) {
|
||||
offeredQuantity: quantity,
|
||||
offerTokenHash: hashToken(token),
|
||||
offeredAt: new Date(),
|
||||
offerExpiresAt: new Date(Date.now() + (hours || 24) * 60 * 60 * 1000),
|
||||
offerExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
return token;
|
||||
@@ -113,8 +154,8 @@ async function notifyOffers(offers) {
|
||||
|
||||
/** Offers `quantity` places to one entry (e.g. a supervisor's manual offer) and notifies. */
|
||||
async function makeOffer(entryId, quantity) {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true } });
|
||||
const token = await writeOffer(prisma, entry, quantity, entry.event.waitlistOfferHours);
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true, session: true } });
|
||||
const token = await writeOffer(prisma, entry, quantity, offerExpiry(entry.event.waitlistOfferHours, entry.session));
|
||||
await notifyOffers([{ entryId, token }]);
|
||||
return prisma.waitlistEntry.findUnique({ where: { id: entryId } });
|
||||
}
|
||||
@@ -127,7 +168,7 @@ async function makeOffer(entryId, quantity) {
|
||||
async function processWaitlist(eventId) {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { variants: true } } },
|
||||
include: { eventOptions: { include: { variants: true } }, sessions: true },
|
||||
});
|
||||
if (!event || !event.waitlistEnabled || !event.isActive || event.cashupStatus === 'closed') return 0;
|
||||
if (new Date(event.endDate) < new Date()) return 0;
|
||||
@@ -136,16 +177,20 @@ async function processWaitlist(eventId) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'waitlist:' + eventId}))`;
|
||||
const waiting = await tx.waitlistEntry.findMany({ where: { eventId, status: 'waiting' }, orderBy: { position: 'asc' } });
|
||||
const lines = [...new Map(waiting.map(w => [`${w.eventOptionId}::${w.variantId || ''}`, w])).values()];
|
||||
const lineKey = (w) => `${w.eventOptionId}::${w.variantId || ''}::${w.sessionId || ''}`;
|
||||
const lines = [...new Map(waiting.map(w => [lineKey(w), w])).values()];
|
||||
for (const sample of lines) {
|
||||
const option = event.eventOptions.find(o => o.id === sample.eventOptionId);
|
||||
const variant = sample.variantId ? (option?.variants || []).find(v => v.id === sample.variantId) : null;
|
||||
if (!option) continue;
|
||||
const free = await remainingFor(option, variant, { db: tx });
|
||||
if (!option || !lineOpen(event, sample.sessionId)) continue;
|
||||
// An option switched between per-session and whole-course after people joined.
|
||||
if (!!sample.sessionId !== isPickOption(event, option)) continue;
|
||||
const free = await placesFree(event, option, variant, sample.sessionId, { db: tx });
|
||||
if (free === null || free <= 0) continue;
|
||||
const queue = waiting.filter(w => w.eventOptionId === sample.eventOptionId && (w.variantId || null) === (sample.variantId || null));
|
||||
const session = sample.sessionId ? event.sessions.find(s => s.id === sample.sessionId) : null;
|
||||
const queue = waiting.filter(w => lineKey(w) === lineKey(sample));
|
||||
for (const offer of pickOffers(queue, free, event.waitlistPartialOffers)) {
|
||||
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, event.waitlistOfferHours);
|
||||
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, offerExpiry(event.waitlistOfferHours, session));
|
||||
made.push({ entryId: offer.entryId, token });
|
||||
}
|
||||
}
|
||||
@@ -183,7 +228,7 @@ async function findOfferByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 100) return null;
|
||||
return prisma.waitlistEntry.findUnique({
|
||||
where: { offerTokenHash: hashToken(token) },
|
||||
include: { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true } } },
|
||||
include: { event: { include: { sessions: true } }, eventOption: true, variant: true, session: true, user: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,6 +237,10 @@ module.exports = {
|
||||
waitlistError,
|
||||
hashToken,
|
||||
pickOffers,
|
||||
placesFree,
|
||||
lineOpen,
|
||||
offerExpiry,
|
||||
lineName,
|
||||
makeOffer,
|
||||
processWaitlist,
|
||||
expireOffers,
|
||||
|
||||
@@ -297,7 +297,36 @@ async function sendPdf(toPhone, localPdfPath, filename, caption) {
|
||||
setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* WAWP doesn't sign webhooks, so the webhook URL carries a secret (?secret=…) and incoming
|
||||
* messages are only acted on when it matches. Made on first use and kept in AppSetting.
|
||||
*/
|
||||
async function getWebhookSecret() {
|
||||
const prisma = require('../config/db');
|
||||
const row = await prisma.appSetting.findUnique({ where: { key: 'WAWP_WEBHOOK_SECRET' } });
|
||||
if (row?.value) return row.value;
|
||||
const secret = require('crypto').randomBytes(24).toString('base64url');
|
||||
await prisma.appSetting.upsert({ where: { key: 'WAWP_WEBHOOK_SECRET' }, update: {}, create: { key: 'WAWP_WEBHOOK_SECRET', value: secret } });
|
||||
const saved = await prisma.appSetting.findUnique({ where: { key: 'WAWP_WEBHOOK_SECRET' } });
|
||||
return saved.value;
|
||||
}
|
||||
|
||||
/** The URL to paste into WAWP's webhook settings (events: message, session.status). */
|
||||
async function webhookUrl() {
|
||||
const base = String(process.env.BACKEND_URL || process.env.API_URL || 'http://localhost:5000').replace(/\/$/, '');
|
||||
return `${base}/api/whatsapp/webhook?secret=${encodeURIComponent(await getWebhookSecret())}`;
|
||||
}
|
||||
|
||||
/** The sender's phone (27…) from a WAWP chat id, or null for groups, channels and LIDs. */
|
||||
function phoneFromChatId(chatId) {
|
||||
const m = String(chatId || '').match(/^(\d+)@c\.us$/);
|
||||
return m ? normalizeZAPhone(m[1]) : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getWebhookSecret,
|
||||
webhookUrl,
|
||||
phoneFromChatId,
|
||||
getConfig,
|
||||
setConfig,
|
||||
normalizeZAPhone,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user