Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8933a1fe77 | ||
|
|
30e5db82df | ||
|
|
f99b84858e | ||
|
|
7c3f4694e2 | ||
|
|
b441ffac60 | ||
|
|
6010641629 | ||
|
|
e338768f6e | ||
|
|
cc44e62379 | ||
|
|
5aba2e735f | ||
|
|
48660732fe |
@@ -0,0 +1,129 @@
|
||||
# Checks every push and pull request: backend tests, frontend lint/typecheck/build, and
|
||||
# the Playwright end-to-end tests. Pushing a version tag (v1.2.3) also deploys, once the
|
||||
# DEPLOY_* secrets are set (Repository → Settings → Actions → Secrets).
|
||||
#
|
||||
# Needs a Gitea Actions runner with the "ubuntu-latest" label (Docker), e.g. act_runner:
|
||||
# https://docs.gitea.com/usage/actions/act-runner
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
# Throwaway values for the test database and tokens — never production secrets.
|
||||
JWT_SECRET: ci-only-jwt-secret-not-for-production
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci
|
||||
E2E_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci_e2e
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: events_ci
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: backend
|
||||
- name: Migrations apply cleanly
|
||||
run: npx prisma migrate deploy
|
||||
working-directory: backend
|
||||
- run: npm test
|
||||
working-directory: backend
|
||||
|
||||
frontend:
|
||||
name: Frontend lint, typecheck, build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- run: npm ci
|
||||
working-directory: frontend
|
||||
- run: npx eslint src
|
||||
working-directory: frontend
|
||||
- run: npx tsc --noEmit -p .
|
||||
working-directory: frontend
|
||||
- run: npm run build
|
||||
working-directory: frontend
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:5000
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
|
||||
e2e:
|
||||
name: End-to-end tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: events_ci
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- run: npm ci
|
||||
- run: npm ci
|
||||
working-directory: backend
|
||||
- run: npm ci
|
||||
working-directory: frontend
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npx playwright test
|
||||
env:
|
||||
CI: "true"
|
||||
- name: Keep the report when tests fail
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
e2e/report
|
||||
e2e/test-results
|
||||
retention-days: 7
|
||||
|
||||
deploy:
|
||||
name: Deploy tagged release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend, e2e]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Deploy over SSH (skipped until DEPLOY_HOST is set)
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
if [ -z "$DEPLOY_HOST" ]; then
|
||||
echo "DEPLOY_HOST isn't set — skipping the deploy."
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p ~/.ssh
|
||||
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"cd '${DEPLOY_PATH:-~/hope-events}' && bash scripts/deploy.sh '$TAG'"
|
||||
+6
-1
@@ -39,4 +39,9 @@ hope-events-deployment-setup.txt
|
||||
|
||||
# runtime data stores (mutated by the running app, not source)
|
||||
backend/data/scheduled-emails.json
|
||||
backend/data/banner.json
|
||||
backend/data/banner.json
|
||||
# end-to-end tests
|
||||
e2e/.data/
|
||||
e2e/report/
|
||||
e2e/test-results/
|
||||
frontend/.next-e2e/
|
||||
|
||||
@@ -7,6 +7,57 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
+42
-11
@@ -97,6 +97,19 @@ Feedback → Attendance**.
|
||||
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
|
||||
@@ -161,7 +174,8 @@ Feedback → Attendance**.
|
||||
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
|
||||
@@ -529,17 +543,25 @@ falling back to a general guide.
|
||||
|
||||
## Background jobs
|
||||
|
||||
Timed jobs run on a job queue in Postgres (pg-boss, `backend/src/jobs/index.js`): each
|
||||
runs once per schedule even with several server processes, every run is recorded,
|
||||
failed runs are retried twice, and a slow run never overlaps the next. Admin → Site
|
||||
Settings → Background jobs shows each job's schedule, next run and last 10 runs, with
|
||||
"Run now". Times are in `APP_TIMEZONE` (default Africa/Johannesburg). Change a schedule
|
||||
with `JOB_SCHEDULE_<NAME>` (cron); `JOBS_ENABLED=false` turns them all off. If the queue
|
||||
can't start, the same jobs run on in-process timers.
|
||||
|
||||
| Job | Schedule |
|
||||
|-----|----------|
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
|
||||
| Daily event summary email | 07:00 daily |
|
||||
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
|
||||
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) |
|
||||
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) |
|
||||
| Session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false` to disable) |
|
||||
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
|
||||
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
|
||||
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h (not on the queue) |
|
||||
| `daily-summary` — daily event summary email | 07:00 daily (`DAILY_SUMMARY_ENABLED=false` to disable) |
|
||||
| `waitlist` — expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false`) |
|
||||
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
|
||||
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
|
||||
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
|
||||
| `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) |
|
||||
| `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) |
|
||||
| `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
|
||||
|
||||
---
|
||||
|
||||
@@ -551,7 +573,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).
|
||||
|
||||
---
|
||||
|
||||
@@ -81,8 +81,26 @@ Documentation: see Frontend README for environment and scripts.
|
||||
- npm run dev
|
||||
- Next.js dev server runs at http://localhost:3000 by default; if it conflicts with backend, it will pick another port (e.g., 3001). Ensure NEXT_PUBLIC_API_URL points to the backend port.
|
||||
|
||||
## Tests
|
||||
|
||||
- Backend unit tests: `cd backend && npm test`
|
||||
- End-to-end tests (Playwright), from the repo root:
|
||||
- once: `npm install` and `npx playwright install chromium`
|
||||
- `npm run test:e2e`
|
||||
- They start their own backend (port 5100) and frontend (port 3100) against a separate
|
||||
database — `backend/.env`'s `DATABASE_URL` with `_e2e` added to the name, or
|
||||
`E2E_DATABASE_URL` — which is dropped, recreated and seeded on every run. Your own dev
|
||||
servers and database aren't touched. The database user needs permission to create
|
||||
databases.
|
||||
- CI: `.gitea/workflows/ci.yml` runs all of the above on every push once a Gitea Actions
|
||||
runner is registered. Pushing a `v*` tag also deploys with `scripts/deploy.sh` when the
|
||||
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY` (and optional `DEPLOY_PATH`) secrets are set.
|
||||
|
||||
## Deployment
|
||||
|
||||
To update a running install to a release: `bash scripts/deploy.sh v1.34.0` on the server
|
||||
(backs up the database, migrates, builds, reloads PM2).
|
||||
|
||||
### Backend Deployment
|
||||
|
||||
1. Provision a PostgreSQL database and set DATABASE_URL.
|
||||
|
||||
@@ -29,6 +29,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Post-event feedback](#post-event-feedback)
|
||||
- [Registration funnel](#registration-funnel)
|
||||
- [Impersonation](#impersonation)
|
||||
- [Background jobs](#background-jobs)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -286,6 +287,22 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
|
||||
---
|
||||
|
||||
## Background jobs
|
||||
|
||||
Timed jobs run on a pg-boss queue in Postgres (schema `pgboss`, created on first start) —
|
||||
see `backend/src/jobs/index.js`.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/admin/jobs` | admin | `{ mode: "queue" \| "timers" \| "off", timezone, jobs: [{ name, label, cron, enabled, envFlag, nextRun, recent: [{ state, startedOn, completedOn, output }] }] }` |
|
||||
| POST | `/api/admin/jobs/:name/run` | admin | Run a job now: `{ queued: true, id }` (or `{ queued: false, output }` in timer mode). Audit-logged as `job_run_manual` |
|
||||
|
||||
Env: `JOBS_ENABLED=false` turns every job off; each job's own flag (`WAITLIST_JOB_ENABLED`, …)
|
||||
turns it off; `JOB_SCHEDULE_<NAME>` (e.g. `JOB_SCHEDULE_DAILY_SUMMARY="0 6 * * *"`) changes its
|
||||
cron schedule; times are in `APP_TIMEZONE` (default Africa/Johannesburg).
|
||||
|
||||
---
|
||||
|
||||
## Impersonation
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
@@ -331,7 +348,7 @@ Data handling:
|
||||
Notes:
|
||||
- New field types `rating` (answers `"1"`–`"5"`) and `multiple_choice` (`options`: array of strings). They can be used on attendee forms too.
|
||||
- Only HMACs of link tokens are stored, and a reminder issues a new link.
|
||||
- A job every 15 minutes (`FEEDBACK_JOB_ENABLED`, `FEEDBACK_JOB_INTERVAL_MS`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
|
||||
- A job every 15 minutes (`feedback` job; `FEEDBACK_JOB_ENABLED`, `JOB_SCHEDULE_FEEDBACK`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
|
||||
|
||||
## Sponsor a spot
|
||||
|
||||
@@ -434,13 +451,28 @@ 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 |
|
||||
| `sessions` | `[{ id?, label?, startsAt, endsAt, location?, cancelled? }]` — 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 |
|
||||
| `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 }`.
|
||||
@@ -464,7 +496,7 @@ are for the session running when downloaded; synced scans count for the session
|
||||
| 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 (`SESSION_JOBS_ENABLED`, `SESSION_JOBS_INTERVAL_MS`) sends due session
|
||||
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.
|
||||
|
||||
@@ -476,10 +508,10 @@ Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitl
|
||||
|
||||
| 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" }` |
|
||||
@@ -488,9 +520,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
|
||||
|
||||
|
||||
Generated
+67
-50
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.33.0",
|
||||
"version": "1.35.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.33.0",
|
||||
"version": "1.35.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
@@ -15,6 +15,7 @@
|
||||
"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",
|
||||
@@ -25,6 +26,7 @@
|
||||
"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"
|
||||
@@ -1615,9 +1617,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1639,9 +1638,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1663,9 +1659,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1687,9 +1680,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1711,9 +1701,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1735,9 +1722,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2707,9 +2691,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2724,9 +2705,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2741,9 +2719,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2758,9 +2733,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2775,9 +2747,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2792,9 +2761,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2809,9 +2775,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2826,9 +2789,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2843,9 +2803,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2860,9 +2817,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4143,6 +4097,19 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||
"deprecated": "v4 is no longer maintained, upgrade to v5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7134,6 +7101,15 @@
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -7761,6 +7737,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-boss": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-10.4.2.tgz",
|
||||
"integrity": "sha512-AttEWOtSzn53av8OnCMWEanwRBvjkZCE1y5nLrZnwvkkMnlZ5XpWDpZ7sKI/BYjvi2OVieMX37arD2ACgJ750w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron-parser": "^4.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"serialize-error": "^8.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
|
||||
@@ -8526,6 +8516,33 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
|
||||
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.20.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-error/node_modules/type-fest": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.33.0",
|
||||
"version": "1.35.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"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",
|
||||
@@ -33,6 +34,7 @@
|
||||
"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"
|
||||
|
||||
@@ -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[];
|
||||
@@ -237,6 +237,9 @@ model EventOption {
|
||||
stockLimit Int @default(0)
|
||||
// Applied automatically for Staff and above instead of `price` (when lower). Null = no staff price.
|
||||
staffPrice Float?
|
||||
// Multi-day events in "pick sessions" mode: `pick` options are bought per session
|
||||
// (price is per session, stockLimit is places per session); `all` options cover every session.
|
||||
sessionMode OptionSessionMode @default(all)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
registrationOptions RegistrationOption[]
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
@@ -337,11 +340,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
|
||||
@@ -554,6 +565,7 @@ enum AdminAuditAction {
|
||||
attendance_unmarked
|
||||
certificate_issued
|
||||
certificate_revoked
|
||||
job_run_manual
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -1355,6 +1367,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
|
||||
@@ -1370,12 +1384,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])
|
||||
}
|
||||
@@ -1444,6 +1460,11 @@ enum LatePricing {
|
||||
fixed
|
||||
}
|
||||
|
||||
enum OptionSessionMode {
|
||||
all
|
||||
pick
|
||||
}
|
||||
|
||||
enum CheckInWindow {
|
||||
whole_day
|
||||
session_only
|
||||
@@ -1461,11 +1482,15 @@ model EventSession {
|
||||
// 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])
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ async function buildRegister(eventId) {
|
||||
holder: { select: { name: true } },
|
||||
user: { select: { name: true, email: true, phoneNumber: true } },
|
||||
usages: { select: { quantityRedeemed: true, sessionId: true, manual: true } },
|
||||
registrationOption: { select: { eventOption: { select: { name: true } }, variant: { select: { name: 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' },
|
||||
@@ -50,18 +50,28 @@ async function buildRegister(eventId) {
|
||||
|
||||
const activeIds = new Set(active.map(s => s.id));
|
||||
const config = sessionsUtil.normalizeCertificateConfig(event.certificateConfig);
|
||||
const rows = tickets.map(t => {
|
||||
// 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 u of t.usages) {
|
||||
const key = multi ? u.sessionId : WHOLE_EVENT;
|
||||
if (!key) continue;
|
||||
bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1);
|
||||
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 attended = multi ? Object.keys(bySession).filter(k => activeIds.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0);
|
||||
const total = multi ? active.length : 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 || '',
|
||||
@@ -108,7 +118,7 @@ const markAttendance = async (req, res) => {
|
||||
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 } } } });
|
||||
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';
|
||||
@@ -117,6 +127,7 @@ const markAttendance = async (req, res) => {
|
||||
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');
|
||||
@@ -202,8 +213,9 @@ const exportRegister = async (req, res) => {
|
||||
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) : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false });
|
||||
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;
|
||||
@@ -223,7 +235,7 @@ const exportRegister = async (req, res) => {
|
||||
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] : '✓') : ''),
|
||||
...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 : '',
|
||||
]);
|
||||
|
||||
@@ -250,7 +250,12 @@ async function basketBuyer(req, forUserId) {
|
||||
/** Prices a basket the same way registration will, as coupon/bundle lines. */
|
||||
async function priceBasket(event, options, buyer) {
|
||||
const registrationOptions = [];
|
||||
for (const o of options || []) {
|
||||
// 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;
|
||||
|
||||
@@ -13,14 +13,21 @@ const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { se
|
||||
|
||||
// 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.
|
||||
function withSchedule(ev) {
|
||||
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({ ...s, cancelled: !!s.cancelledAt, hasCheckIns: (_count?.usages || 0) > 0 }));
|
||||
// `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,
|
||||
};
|
||||
@@ -32,6 +39,10 @@ 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';
|
||||
@@ -342,7 +353,8 @@ const getEvents = async (req, res) => {
|
||||
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');
|
||||
@@ -551,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;
|
||||
|
||||
@@ -569,6 +591,7 @@ const getEventById = async (req, res) => {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -579,7 +602,7 @@ const getEventById = async (req, res) => {
|
||||
})),
|
||||
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 });
|
||||
@@ -915,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: {
|
||||
@@ -926,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) {
|
||||
@@ -952,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 },
|
||||
@@ -962,6 +987,7 @@ const updateEventOption = async (req, res) => {
|
||||
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
|
||||
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
|
||||
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
|
||||
sessionMode: sessionMode !== undefined ? (sessionMode === 'pick' ? 'pick' : 'all') : eventOption.sessionMode,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const jobs = require('../jobs');
|
||||
|
||||
// @desc Background jobs: schedule, next run and the last 10 runs of each
|
||||
// @route GET /api/admin/jobs
|
||||
// @access Private/Admin
|
||||
const listJobs = async (req, res) => {
|
||||
try {
|
||||
res.json(await jobs.jobStatus(prisma));
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Run a background job now
|
||||
// @route POST /api/admin/jobs/:name/run
|
||||
// @access Private/Admin
|
||||
const runJobNow = async (req, res) => {
|
||||
try {
|
||||
const result = await jobs.runNow(req.params.name);
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'job_run_manual',
|
||||
targetType: 'Job', targetId: req.params.name, metadata: { queued: result.queued }, ip: getClientIp(req),
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listJobs, runJobNow };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -52,7 +53,7 @@ const ACTIVE_COUPONS_INCLUDE = {
|
||||
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 } = require('../utils/sessions');
|
||||
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
|
||||
@@ -92,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 };
|
||||
}
|
||||
|
||||
@@ -222,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`);
|
||||
@@ -239,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;
|
||||
@@ -259,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;
|
||||
@@ -320,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([
|
||||
@@ -337,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 {
|
||||
@@ -348,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: {
|
||||
@@ -395,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: {
|
||||
@@ -941,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) {
|
||||
@@ -962,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);
|
||||
@@ -975,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));
|
||||
@@ -986,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);
|
||||
@@ -1158,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([
|
||||
@@ -1177,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) }),
|
||||
}
|
||||
})
|
||||
]);
|
||||
@@ -1188,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: {
|
||||
@@ -1256,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: {
|
||||
@@ -1465,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);
|
||||
@@ -1474,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);
|
||||
|
||||
@@ -1576,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');
|
||||
|
||||
@@ -92,6 +92,7 @@ const getUserTickets = async (req, res) => {
|
||||
include: {
|
||||
eventOption: true,
|
||||
variant: true,
|
||||
session: true,
|
||||
registration: true
|
||||
}
|
||||
},
|
||||
@@ -262,6 +263,8 @@ 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",
|
||||
@@ -314,7 +317,7 @@ const getScanPreview = async (req, res) => {
|
||||
if (r.scheduleType === 'sessions') {
|
||||
const sessionsUtil = require('../utils/sessions');
|
||||
const schedule = await sessionsUtil.eventSchedule(r.eventId);
|
||||
const found = sessionsUtil.scanSession(schedule, schedule.sessions, req.query.sessionId || null);
|
||||
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) };
|
||||
@@ -361,6 +364,8 @@ const scanTicket = async (req, res) => {
|
||||
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",
|
||||
@@ -381,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) {
|
||||
@@ -438,7 +443,7 @@ const scanTicket = async (req, res) => {
|
||||
if (r.scheduleType === 'sessions') {
|
||||
sessionsUtil = require('../utils/sessions');
|
||||
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
|
||||
const found = sessionsUtil.scanSession(schedule, schedule.sessions, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null);
|
||||
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;
|
||||
}
|
||||
@@ -544,7 +549,7 @@ 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, scheduleType: true, sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } } } },
|
||||
@@ -914,7 +919,7 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
|
||||
// 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)
|
||||
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;
|
||||
@@ -980,7 +985,7 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
|
||||
const ticketInclude = {
|
||||
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
|
||||
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,
|
||||
@@ -1088,7 +1093,11 @@ 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 = require('../utils/sessions').ticketDateText(tickets[0].event, 'long') || (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`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+13
-223
@@ -661,6 +661,8 @@ app.get('/docs', async (req, res) => {
|
||||
|
||||
{ title: 'Impersonation', base: '/api/admin', endpoints: [
|
||||
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
|
||||
{ method:'GET', path:'/api/admin/jobs', auth:'admin', desc:'Background jobs: queue mode, timezone, and each job schedule, next run and last 10 runs' },
|
||||
{ method:'POST', path:'/api/admin/jobs/:name/run', auth:'admin', desc:'Run a background job now (queued for a worker); audit-logged' },
|
||||
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
|
||||
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
|
||||
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
|
||||
@@ -1445,229 +1447,9 @@ app.listen(PORT, () => {
|
||||
console.warn('[attachments sync] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Schedule daily summaries at 07:00 local time (configurable)
|
||||
try {
|
||||
const enabled = String(process.env.DAILY_SUMMARY_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { sendDailyEventSummaries } = require('./utils/notifications');
|
||||
function msUntilNext(hour, minute) {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
if (next <= now) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
return next.getTime() - now.getTime();
|
||||
}
|
||||
async function scheduleNext() {
|
||||
const delay = msUntilNext(7, 0);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
console.log('[daily summaries] Running daily event summaries...');
|
||||
await sendDailyEventSummaries(new Date());
|
||||
console.log('[daily summaries] Completed sending daily event summaries.');
|
||||
} catch (e) {
|
||||
console.error('[daily summaries] Failed:', e?.message || e);
|
||||
} finally {
|
||||
// Schedule next run
|
||||
scheduleNext();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
scheduleNext();
|
||||
console.log('[daily summaries] Scheduler initialized (07:00 local time). Set DAILY_SUMMARY_ENABLED=false to disable.');
|
||||
} else {
|
||||
console.log('[daily summaries] Scheduler disabled by env DAILY_SUMMARY_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[daily summaries] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Waitlist job — expire unclaimed offers and offer any free places to the next people.
|
||||
try {
|
||||
const enabled = String(process.env.WAITLIST_JOB_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runWaitlistJob } = require('./utils/waitlist');
|
||||
const everyMs = parseInt(process.env.WAITLIST_JOB_INTERVAL_MS || String(10 * 60 * 1000), 10);
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await runWaitlistJob();
|
||||
if (r.expired || r.offers) console.log(`[waitlist] ${r.expired} event(s) with expired offers, ${r.offers} new offer(s).`);
|
||||
} catch (e) {
|
||||
console.warn('[waitlist] Job failed:', e?.message || e);
|
||||
}
|
||||
}, everyMs);
|
||||
console.log(`[waitlist] Job every ${Math.round(everyMs / 60000)} min.`);
|
||||
} else {
|
||||
console.log('[waitlist] Job disabled by env WAITLIST_JOB_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[waitlist] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Funnel analytics — once a day, roll raw rows older than 90 days into daily totals.
|
||||
try {
|
||||
const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { rollupOldEvents } = require('./utils/analytics');
|
||||
const run = async () => {
|
||||
try {
|
||||
const n = await rollupOldEvents();
|
||||
if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`);
|
||||
} catch (e) {
|
||||
console.warn('[analytics] Rollup failed:', e?.message || e);
|
||||
}
|
||||
};
|
||||
setTimeout(run, 5 * 60 * 1000);
|
||||
setInterval(run, 24 * 60 * 60 * 1000);
|
||||
console.log('[analytics] Rollup daily.');
|
||||
} else {
|
||||
console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[analytics] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Feedback job — send post-event feedback forms at their send time, and the one
|
||||
// reminder 3 days later.
|
||||
try {
|
||||
const enabled = String(process.env.FEEDBACK_JOB_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runFeedbackJob } = require('./utils/feedback');
|
||||
const everyMs = parseInt(process.env.FEEDBACK_JOB_INTERVAL_MS || String(15 * 60 * 1000), 10);
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await runFeedbackJob();
|
||||
if (r.sent || r.reminded) console.log(`[feedback] ${r.sent} invite(s), ${r.reminded} reminder(s) sent.`);
|
||||
} catch (e) {
|
||||
console.warn('[feedback] Job failed:', e?.message || e);
|
||||
}
|
||||
}, everyMs);
|
||||
console.log(`[feedback] Job every ${Math.round(everyMs / 60000)} min.`);
|
||||
} else {
|
||||
console.log('[feedback] Job disabled by env FEEDBACK_JOB_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[feedback] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Multi-day events — reminders before each session (when the event has them on), and
|
||||
// completion certificates once an event with certificates on has ended.
|
||||
try {
|
||||
const enabled = String(process.env.SESSION_JOBS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runSessionReminderJob } = require('./utils/sessionReminders');
|
||||
const { runCertificateJob } = require('./utils/certificates');
|
||||
const everyMs = parseInt(process.env.SESSION_JOBS_INTERVAL_MS || String(5 * 60 * 1000), 10);
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await runSessionReminderJob();
|
||||
if (r.sent) console.log(`[sessions] ${r.sent} reminder(s) sent for ${r.sessions} session(s).`);
|
||||
} catch (e) {
|
||||
console.warn('[sessions] Reminder job failed:', e?.message || e);
|
||||
}
|
||||
try {
|
||||
const c = await runCertificateJob();
|
||||
if (c.issued) console.log(`[certificates] ${c.issued} issued, ${c.sent} sent.`);
|
||||
} catch (e) {
|
||||
console.warn('[certificates] Job failed:', e?.message || e);
|
||||
}
|
||||
}, everyMs);
|
||||
console.log(`[sessions] Reminder and certificate job every ${Math.round(everyMs / 60000)} min.`);
|
||||
} else {
|
||||
console.log('[sessions] Jobs disabled by env SESSION_JOBS_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[sessions] 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 {
|
||||
@@ -1729,6 +1511,14 @@ app.listen(PORT, () => {
|
||||
// Log unhandled promise rejections without killing the server, since a single
|
||||
// missed .catch() on fire-and-forget notification code (email/WhatsApp sends)
|
||||
// would otherwise take the whole app down.
|
||||
// On restart/stop (PM2 sends SIGINT), let a running background job finish first.
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, async () => {
|
||||
try { await require('./jobs').stopJobs(); } catch { /* exiting anyway */ }
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (err) => {
|
||||
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
|
||||
console.error(err?.stack || err);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Background jobs — one registry, run by pg-boss (a job queue stored in Postgres).
|
||||
*
|
||||
* Why a queue instead of setInterval:
|
||||
* - Schedules live in the database, so a job runs once per schedule even when more than
|
||||
* one backend process is running (PM2 cluster, two servers on one database).
|
||||
* - Every run is recorded (state, start/end, result or error), shown on the admin
|
||||
* Background jobs page, which can also run a job straight away.
|
||||
* - A run that fails is retried, and a slow run never overlaps the next one.
|
||||
*
|
||||
* Each job can be switched off with its env flag (e.g. WAITLIST_JOB_ENABLED=false), and
|
||||
* its schedule changed with JOB_SCHEDULE_<NAME> (cron, e.g. JOB_SCHEDULE_WAITLIST="*\/5 * * * *").
|
||||
* JOBS_ENABLED=false turns all of them off (used by the end-to-end tests).
|
||||
* If the queue can't start (e.g. the database user can't create the pgboss schema), the
|
||||
* same jobs run on in-process timers instead, as before.
|
||||
*/
|
||||
|
||||
const cronParser = require('cron-parser');
|
||||
|
||||
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
|
||||
const SCHEMA = 'pgboss';
|
||||
|
||||
const JOBS = [
|
||||
{
|
||||
name: 'daily-summary', label: 'Daily event summary email', cron: '0 7 * * *', env: 'DAILY_SUMMARY_ENABLED',
|
||||
run: async () => { await require('../utils/notifications').sendDailyEventSummaries(new Date()); return { sent: true }; },
|
||||
},
|
||||
{
|
||||
name: 'waitlist', label: 'Waitlist: expire offers, offer freed places', cron: '*/10 * * * *', env: 'WAITLIST_JOB_ENABLED',
|
||||
run: () => require('../utils/waitlist').runWaitlistJob(),
|
||||
},
|
||||
{
|
||||
name: 'feedback', label: 'Post-event feedback forms and reminders', cron: '*/15 * * * *', env: 'FEEDBACK_JOB_ENABLED',
|
||||
run: () => require('../utils/feedback').runFeedbackJob(),
|
||||
},
|
||||
{
|
||||
name: 'sessions', label: 'Session reminders and certificates', cron: '*/5 * * * *', env: 'SESSION_JOBS_ENABLED',
|
||||
run: async () => {
|
||||
const reminders = await require('../utils/sessionReminders').runSessionReminderJob();
|
||||
const certificates = await require('../utils/certificates').runCertificateJob();
|
||||
return { reminders, certificates };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
|
||||
run: async () => {
|
||||
const { sendConsentReminders, purgeConsentMedicalData } = require('../utils/consent');
|
||||
return { reminded: await sendConsentReminders(new Date()), purged: await purgeConsentMedicalData(new Date()) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'analytics-rollup', label: 'Funnel analytics: roll up visits older than 90 days', cron: '15 4 * * *', env: 'ANALYTICS_ROLLUP_ENABLED',
|
||||
run: async () => ({ rolledUp: await require('../utils/analytics').rollupOldEvents() }),
|
||||
},
|
||||
{
|
||||
name: 'backup', label: 'Database backup', cron: '0 2 * * *', env: 'BACKUP_ENABLED',
|
||||
run: async () => ({ file: (await require('../utils/backupUtils').runBackup()).filename }),
|
||||
},
|
||||
{
|
||||
name: 'temp-cleanup', label: 'Delete temp files older than 7 days', cron: '0 3 * * *', env: null,
|
||||
run: async () => require('../utils/cleanupTemp').cleanupTempFiles(),
|
||||
},
|
||||
];
|
||||
|
||||
const flag = (name, def = true) => {
|
||||
const v = process.env[name];
|
||||
return v === undefined || v === '' ? def : String(v).toLowerCase() !== 'false';
|
||||
};
|
||||
|
||||
/** Whether a job is switched on (JOBS_ENABLED and its own flag). */
|
||||
function isEnabled(job) {
|
||||
return flag('JOBS_ENABLED') && (!job.env || flag(job.env));
|
||||
}
|
||||
|
||||
/** A job's cron schedule, with any JOB_SCHEDULE_<NAME> override (checked). */
|
||||
function cronFor(job) {
|
||||
const override = process.env[`JOB_SCHEDULE_${job.name.toUpperCase().replace(/-/g, '_')}`];
|
||||
if (override) {
|
||||
try {
|
||||
cronParser.parseExpression(override, { tz: TIMEZONE });
|
||||
return override;
|
||||
} catch {
|
||||
console.warn(`[jobs] Ignoring invalid schedule for ${job.name}: "${override}"`);
|
||||
}
|
||||
}
|
||||
return job.cron;
|
||||
}
|
||||
|
||||
/** When a cron schedule next fires after `from`. */
|
||||
function nextRun(cron, from = new Date()) {
|
||||
return cronParser.parseExpression(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
|
||||
}
|
||||
|
||||
function summarize(result) {
|
||||
try {
|
||||
const s = JSON.stringify(result ?? null);
|
||||
return s.length > 300 ? `${s.slice(0, 300)}…` : s;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a job's result says nothing happened (all zeros/false/empty). */
|
||||
function isNoop(value) {
|
||||
if (value === null || value === undefined || value === 0 || value === false || value === '') return true;
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
if (typeof value === 'object') return Object.values(value).every(isNoop);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runJob(job) {
|
||||
const started = Date.now();
|
||||
const result = await job.run();
|
||||
// Quiet when nothing happened, like the old timers.
|
||||
if (!isNoop(result)) console.log(`[jobs] ${job.name} done in ${Date.now() - started} ms: ${summarize(result)}`);
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
// ─── Runner ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let boss = null;
|
||||
let mode = 'off'; // 'queue' | 'timers' | 'off'
|
||||
const timers = new Map();
|
||||
// Timer mode keeps its own short history so the admin page still has something to show.
|
||||
const timerHistory = new Map();
|
||||
|
||||
function startTimers(jobs) {
|
||||
mode = 'timers';
|
||||
for (const job of jobs) {
|
||||
const cron = cronFor(job);
|
||||
const scheduleNext = () => {
|
||||
const delay = Math.max(1000, nextRun(cron).getTime() - Date.now());
|
||||
timers.set(job.name, setTimeout(async () => {
|
||||
const entry = { state: 'active', startedOn: new Date(), completedOn: null, output: null };
|
||||
try {
|
||||
entry.output = await runJob(job);
|
||||
entry.state = 'completed';
|
||||
} catch (e) {
|
||||
entry.state = 'failed';
|
||||
entry.output = { message: e?.message || String(e) };
|
||||
console.warn(`[jobs] ${job.name} failed:`, e?.message || e);
|
||||
} finally {
|
||||
entry.completedOn = new Date();
|
||||
timerHistory.set(job.name, [entry, ...(timerHistory.get(job.name) || [])].slice(0, 10));
|
||||
scheduleNext();
|
||||
}
|
||||
}, Math.min(delay, 2 ** 31 - 1)));
|
||||
};
|
||||
scheduleNext();
|
||||
}
|
||||
console.log(`[jobs] Running ${jobs.length} job(s) on in-process timers.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the background jobs. Call once after the server is listening.
|
||||
* @returns {Promise<'queue'|'timers'|'off'>}
|
||||
*/
|
||||
async function startJobs() {
|
||||
const enabled = JOBS.filter(isEnabled);
|
||||
if (!flag('JOBS_ENABLED')) {
|
||||
console.log('[jobs] All background jobs disabled by env JOBS_ENABLED=false');
|
||||
mode = 'off';
|
||||
return mode;
|
||||
}
|
||||
try {
|
||||
const PgBoss = require('pg-boss');
|
||||
boss = new PgBoss({ connectionString: process.env.DATABASE_URL, schema: SCHEMA, application_name: 'hope-events-jobs' });
|
||||
boss.on('error', (e) => console.warn('[jobs] Queue error:', e?.message || e));
|
||||
await boss.start();
|
||||
const existing = new Set((await boss.getQueues()).map(q => q.name));
|
||||
for (const job of JOBS) {
|
||||
if (!existing.has(job.name)) {
|
||||
// stately: at most one waiting and one running, so a slow run never piles up.
|
||||
await boss.createQueue(job.name, { policy: 'stately', retryLimit: 2, retryDelay: 60, expireInSeconds: 60 * 60 });
|
||||
}
|
||||
if (isEnabled(job)) {
|
||||
await boss.schedule(job.name, cronFor(job), null, { tz: TIMEZONE });
|
||||
await boss.work(job.name, async () => {
|
||||
try {
|
||||
return await runJob(job);
|
||||
} catch (e) {
|
||||
console.warn(`[jobs] ${job.name} failed:`, e?.message || e);
|
||||
throw e; // recorded as failed, and retried
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await boss.unschedule(job.name).catch(() => {});
|
||||
}
|
||||
}
|
||||
mode = 'queue';
|
||||
const off = JOBS.filter(j => !isEnabled(j)).map(j => j.name);
|
||||
console.log(`[jobs] Job queue started: ${enabled.map(j => j.name).join(', ')}${off.length ? ` (off: ${off.join(', ')})` : ''}.`);
|
||||
} catch (e) {
|
||||
console.warn('[jobs] Job queue unavailable, falling back to timers:', e?.message || e);
|
||||
if (boss) { try { await boss.stop({ graceful: false }); } catch { /* ignore */ } }
|
||||
boss = null;
|
||||
startTimers(enabled);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
async function stopJobs() {
|
||||
for (const t of timers.values()) clearTimeout(t);
|
||||
timers.clear();
|
||||
if (boss) {
|
||||
await boss.stop({ graceful: true, timeout: 10000 }).catch(() => {});
|
||||
boss = null;
|
||||
}
|
||||
mode = 'off';
|
||||
}
|
||||
|
||||
/** Runs a job now (admin "Run now"). Queue mode: queued for a worker; timer mode: inline. */
|
||||
async function runNow(name) {
|
||||
const job = JOBS.find(j => j.name === name);
|
||||
if (!job) {
|
||||
const err = new Error('Unknown job');
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
if (mode === 'queue' && isEnabled(job)) {
|
||||
const id = await boss.send(job.name, null, { singletonKey: `${job.name}-manual` });
|
||||
return { queued: true, id };
|
||||
}
|
||||
const output = await runJob(job);
|
||||
const entry = { state: 'completed', startedOn: new Date(), completedOn: new Date(), output };
|
||||
timerHistory.set(job.name, [entry, ...(timerHistory.get(job.name) || [])].slice(0, 10));
|
||||
return { queued: false, output };
|
||||
}
|
||||
|
||||
/** Each job with its schedule, next run and recent runs, for the admin page. */
|
||||
async function jobStatus(prisma) {
|
||||
const recent = new Map();
|
||||
if (mode === 'queue') {
|
||||
const rows = await prisma.$queryRawUnsafe(`
|
||||
SELECT name, state, created_on AS "createdOn", started_on AS "startedOn", completed_on AS "completedOn", output
|
||||
FROM (
|
||||
SELECT name, state, created_on, started_on, completed_on, output,
|
||||
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
|
||||
FROM (
|
||||
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job
|
||||
UNION ALL
|
||||
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.archive
|
||||
) all_jobs
|
||||
WHERE name = ANY($1::text[])
|
||||
) ranked
|
||||
WHERE rn <= 10
|
||||
ORDER BY "createdOn" DESC`, JOBS.map(j => j.name));
|
||||
for (const r of rows) recent.set(r.name, [...(recent.get(r.name) || []), r]);
|
||||
} else {
|
||||
for (const [name, list] of timerHistory) recent.set(name, list);
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
timezone: TIMEZONE,
|
||||
jobs: JOBS.map(job => {
|
||||
const cron = cronFor(job);
|
||||
const enabled = isEnabled(job);
|
||||
return {
|
||||
name: job.name, label: job.label, cron, enabled, envFlag: job.env,
|
||||
nextRun: enabled && mode !== 'off' ? nextRun(cron) : null,
|
||||
recent: recent.get(job.name) || [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { JOBS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus };
|
||||
@@ -2,9 +2,12 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listAuditLog } = require('../controllers/adminAuditController');
|
||||
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
|
||||
const { listJobs, runJobNow } = require('../controllers/jobsController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/audit-log', protect, admin, listAuditLog);
|
||||
router.get('/jobs', protect, admin, listJobs);
|
||||
router.post('/jobs/:name/run', protect, admin, runJobNow);
|
||||
router.get('/impersonation-sessions', protect, admin, listSessions);
|
||||
// Called with the impersonation token itself (which belongs to the viewed user).
|
||||
router.post('/impersonate/end', protect, endImpersonation);
|
||||
|
||||
@@ -17,7 +17,7 @@ const path = require('path');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const QRCode = require('qrcode');
|
||||
const prisma = require('../config/db');
|
||||
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, TIMEZONE } = require('./sessions');
|
||||
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, groupTickets, TIMEZONE } = require('./sessions');
|
||||
|
||||
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const AUTO_ISSUE_DAYS = 14;
|
||||
@@ -57,18 +57,25 @@ async function attendanceForCertificates(eventId) {
|
||||
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: tickets.map(t => {
|
||||
const m = bySession.get(t.id) || new Map();
|
||||
const attended = [...m.entries()].filter(([sid, qty]) => activeIds.has(sid) && qty > 0).length;
|
||||
return { ticket: t, attended, total: active.length };
|
||||
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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ 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' };
|
||||
@@ -73,6 +74,7 @@ const CONFLICT_TEXT = {
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
@@ -145,8 +147,10 @@ async function snapshotFor(lock) {
|
||||
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
|
||||
noSessionToday: schedule?.scheduleType === 'sessions' && !session,
|
||||
tickets: tickets.map(t => {
|
||||
// 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 ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
|
||||
? (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,
|
||||
@@ -200,6 +204,7 @@ async function syncScans(lock, scans) {
|
||||
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),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* before" and "1 day before" each session, by email and/or WhatsApp.
|
||||
*
|
||||
* Rules:
|
||||
* - Goes to everyone with a ticket for the event (the booker, and named attendees with
|
||||
* - 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
|
||||
@@ -21,10 +21,10 @@ 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 for an event: Map userId → user. */
|
||||
async function recipientsFor(eventId) {
|
||||
/** 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' } } } },
|
||||
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 } },
|
||||
@@ -84,7 +84,6 @@ async function runSessionReminderJob(now = new Date()) {
|
||||
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 } });
|
||||
let recipients = null;
|
||||
for (const session of event.sessions) {
|
||||
const minutesLeft = (new Date(session.startsAt) - now) / 60000;
|
||||
const loggedOffsets = new Set(session.reminders.map(r => r.offset));
|
||||
@@ -102,7 +101,7 @@ async function runSessionReminderJob(now = new Date()) {
|
||||
}
|
||||
}
|
||||
if (!claimed.includes(due[0])) continue; // the closest one was claimed elsewhere
|
||||
recipients = recipients || await recipientsFor(event.id);
|
||||
const recipients = await recipientsFor(event.id, session.id);
|
||||
let count = 0;
|
||||
for (const user of recipients.values()) {
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,17 @@
|
||||
* 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');
|
||||
@@ -70,6 +81,7 @@ function normalizeSessionsInput(list) {
|
||||
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);
|
||||
@@ -102,7 +114,8 @@ function currentSession(sessions, now = new Date(), window = 'whole_day') {
|
||||
}
|
||||
|
||||
/** When public registration closes for an event (Date). */
|
||||
function registrationClosesAt(event, sessions) {
|
||||
/** 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') {
|
||||
@@ -115,6 +128,160 @@ function registrationClosesAt(event, sessions) {
|
||||
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.
|
||||
@@ -204,7 +371,7 @@ async function syncEventSessions(tx, eventId, input) {
|
||||
|
||||
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, order: 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;
|
||||
@@ -225,7 +392,7 @@ async function syncEventSessions(tx, eventId, input) {
|
||||
}
|
||||
|
||||
const SCHEDULE_SELECT = {
|
||||
id: true, endDate: true, registrationDeadline: true, scheduleType: true, checkInWindow: true,
|
||||
id: true, endDate: true, registrationDeadline: true, scheduleType: true, attendanceMode: true, checkInWindow: true,
|
||||
lateJoinEnabled: true, lateJoinUntilSessionId: true, latePricing: true, lateFixedPrice: true,
|
||||
sessions: { orderBy: { startsAt: 'asc' } },
|
||||
};
|
||||
@@ -284,11 +451,30 @@ function scanSession(event, sessions, requestedId = null, now = new Date()) {
|
||||
* (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') {
|
||||
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;
|
||||
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
|
||||
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)`;
|
||||
@@ -299,6 +485,56 @@ function ticketDateText(event, style = 'short') {
|
||||
: `${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);
|
||||
@@ -306,7 +542,8 @@ function redeemedForSession(usages, sessionId) {
|
||||
|
||||
/** Applies late pricing to a resolved option price ({ price, tierId }). */
|
||||
async function withLatePrice(option, resolved) {
|
||||
if (!option?.isMainTicket || !option.eventId || !(resolved.price > 0)) return 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));
|
||||
@@ -334,7 +571,8 @@ async function attendanceBySession(eventId) {
|
||||
module.exports = {
|
||||
TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError,
|
||||
dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession,
|
||||
sessionLabel, scanSession, redeemedForSession, ticketDateText,
|
||||
registrationClosesAt, lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const { JOBS, isEnabled, cronFor, nextRun, isNoop } = require('../src/jobs');
|
||||
|
||||
const job = (name) => JOBS.find(j => j.name === name);
|
||||
|
||||
describe('job registry', () => {
|
||||
const saved = { ...process.env };
|
||||
afterEach(() => { process.env = { ...saved }; });
|
||||
|
||||
test('every job has a valid cron schedule and a unique name', () => {
|
||||
const names = new Set();
|
||||
for (const j of JOBS) {
|
||||
expect(names.has(j.name)).toBe(false);
|
||||
names.add(j.name);
|
||||
expect(() => nextRun(j.cron)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('a job is on unless its own flag or JOBS_ENABLED is false', () => {
|
||||
delete process.env.WAITLIST_JOB_ENABLED;
|
||||
delete process.env.JOBS_ENABLED;
|
||||
expect(isEnabled(job('waitlist'))).toBe(true);
|
||||
process.env.WAITLIST_JOB_ENABLED = 'false';
|
||||
expect(isEnabled(job('waitlist'))).toBe(false);
|
||||
process.env.WAITLIST_JOB_ENABLED = 'true';
|
||||
process.env.JOBS_ENABLED = 'false';
|
||||
expect(isEnabled(job('waitlist'))).toBe(false);
|
||||
expect(isEnabled(job('temp-cleanup'))).toBe(false);
|
||||
});
|
||||
|
||||
test('JOB_SCHEDULE_<NAME> overrides the schedule; an invalid one is ignored', () => {
|
||||
process.env.JOB_SCHEDULE_ANALYTICS_ROLLUP = '30 5 * * *';
|
||||
expect(cronFor(job('analytics-rollup'))).toBe('30 5 * * *');
|
||||
process.env.JOB_SCHEDULE_ANALYTICS_ROLLUP = 'every tuesday';
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(cronFor(job('analytics-rollup'))).toBe(job('analytics-rollup').cron);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('daily jobs fire at local (Johannesburg) time', () => {
|
||||
// 07:00 SAST = 05:00 UTC
|
||||
const next = nextRun('0 7 * * *', new Date('2026-11-04T06:00:00Z'));
|
||||
expect(next.toISOString()).toBe('2026-11-05T05:00:00.000Z');
|
||||
expect(nextRun('0 7 * * *', new Date('2026-11-04T04:00:00Z')).toISOString()).toBe('2026-11-04T05:00:00.000Z');
|
||||
});
|
||||
|
||||
test('isNoop: quiet when nothing happened', () => {
|
||||
expect(isNoop({ expired: 0, offers: 0 })).toBe(true);
|
||||
expect(isNoop({ reminders: { sessions: 0, sent: 0 }, certificates: { issued: 0, sent: 0 } })).toBe(true);
|
||||
expect(isNoop({ deleted: 0, errors: 0 })).toBe(true);
|
||||
expect(isNoop(null)).toBe(true);
|
||||
expect(isNoop({ sent: 3 })).toBe(false);
|
||||
expect(isNoop({ file: 'backup.sql.gz' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,15 @@ const {
|
||||
scanSession,
|
||||
redeemedForSession,
|
||||
sessionLabel,
|
||||
isPickOption,
|
||||
expandPickLines,
|
||||
ticketScanSession,
|
||||
groupTickets,
|
||||
wholeCourseClosesAt,
|
||||
choosesSessions,
|
||||
lineSessions,
|
||||
mergeSessionIds,
|
||||
ticketDateText,
|
||||
} = require('../src/utils/sessions');
|
||||
|
||||
// Africa/Johannesburg is UTC+2 all year, so 16:30Z is 18:30 local.
|
||||
@@ -268,3 +277,93 @@ describe('redeemedForSession / sessionLabel', () => {
|
||||
expect(sessionLabel(list[2], list)).toBe('Session 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pick sessions', () => {
|
||||
const pickEvent = {
|
||||
scheduleType: 'sessions', attendanceMode: 'pick', endDate: wed(32, 18, 30), registrationDeadline: null,
|
||||
lateJoinEnabled: false, sessions: COURSE,
|
||||
eventOptions: [{ id: 'drop', name: 'Drop-in', sessionMode: 'pick' }, { id: 'full', name: 'Full course', sessionMode: 'all' }],
|
||||
};
|
||||
|
||||
test('choosesSessions: pick-mode multi-day events only', () => {
|
||||
expect(choosesSessions(pickEvent)).toBe(true);
|
||||
expect(choosesSessions({ ...pickEvent, attendanceMode: 'whole' })).toBe(false);
|
||||
expect(choosesSessions({ ...pickEvent, scheduleType: 'single' })).toBe(false);
|
||||
});
|
||||
|
||||
test('lineSessions / mergeSessionIds', () => {
|
||||
expect(lineSessions({ sessionId: 's2', sessionIds: [] })).toEqual(['s2']);
|
||||
expect(lineSessions({ sessionId: null, sessionIds: ['s1', 's3'] })).toEqual(['s1', 's3']);
|
||||
expect(lineSessions({ sessionId: null, sessionIds: [] })).toBeNull();
|
||||
expect(mergeSessionIds(['s1'], ['s3', 's1'])).toEqual(['s1', 's3']);
|
||||
expect(mergeSessionIds(['s1'], [])).toEqual([]);
|
||||
});
|
||||
|
||||
test('an all-sessions ticket with sessions ticked only scans on those', () => {
|
||||
const ev = { checkInWindow: 'whole_day' };
|
||||
const onDay2 = new Date(wed(11).getTime() + 30 * 60000);
|
||||
expect(ticketScanSession(ev, COURSE, null, null, onDay2, ['s2', 's4']).session.id).toBe('s2');
|
||||
expect(ticketScanSession(ev, COURSE, null, null, onDay2, ['s1', 's4']).error).toMatch(/isn't booked for Session 2/);
|
||||
expect(ticketScanSession(ev, COURSE, null, null, onDay2, []).session.id).toBe('s2');
|
||||
});
|
||||
|
||||
test('ticket dates list the ticked sessions', () => {
|
||||
const ev = { ...pickEvent, sessions: COURSE };
|
||||
expect(ticketDateText(ev, 'short', null, null, ['s2', 's4'])).toBe('2 sessions · 11 Nov, 25 Nov');
|
||||
expect(ticketDateText(ev, 'long', null, null, ['s2'])).toBe('Wednesday, 11 November 2026 (Session 2)');
|
||||
});
|
||||
|
||||
test('isPickOption needs a pick-mode multi-day event and a pick option', () => {
|
||||
expect(isPickOption(pickEvent, pickEvent.eventOptions[0])).toBe(true);
|
||||
expect(isPickOption(pickEvent, pickEvent.eventOptions[1])).toBe(false);
|
||||
expect(isPickOption({ ...pickEvent, attendanceMode: 'whole' }, pickEvent.eventOptions[0])).toBe(false);
|
||||
});
|
||||
|
||||
test('a pick line becomes one line per session; an all-sessions line keeps its ticked sessions', () => {
|
||||
const out = expandPickLines(pickEvent, [
|
||||
{ eventOptionId: 'drop', quantity: 2, sessionIds: ['s3', 's4', 's3'] },
|
||||
{ eventOptionId: 'full', quantity: 1 },
|
||||
{ eventOptionId: 'full', quantity: 1, sessionIds: ['s2', 's5'] },
|
||||
], { now: wed(5) });
|
||||
expect(out).toEqual([
|
||||
{ eventOptionId: 'drop', quantity: 2, sessionId: 's3' },
|
||||
{ eventOptionId: 'drop', quantity: 2, sessionId: 's4' },
|
||||
{ eventOptionId: 'full', quantity: 1, sessionId: null, sessionIds: [] },
|
||||
{ eventOptionId: 'full', quantity: 1, sessionId: null, sessionIds: ['s2', 's5'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('sessions must be chosen, upcoming, and only on events where people choose them', () => {
|
||||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1 }])).toThrow(/Choose the sessions/);
|
||||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
|
||||
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
|
||||
expect(() => expandPickLines({ ...pickEvent, attendanceMode: 'whole' }, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s3'] }])).toThrow(/covers every session/);
|
||||
expect(() => expandPickLines({ ...pickEvent, requiresParentalConsent: true }, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s3'] }], { now: wed(5) })).toThrow(/youth/);
|
||||
});
|
||||
|
||||
test('staff can still book a session that is in progress', () => {
|
||||
const during = new Date(wed(4).getTime() + 30 * 60000);
|
||||
expect(expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: during, staff: true })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('registration stays open until the last session; whole-course tickets close at the first', () => {
|
||||
expect(registrationClosesAt(pickEvent, COURSE)).toEqual(wed(32));
|
||||
expect(wholeCourseClosesAt(pickEvent, COURSE)).toEqual(wed(4));
|
||||
});
|
||||
|
||||
test('a picked ticket only checks in on its own session', () => {
|
||||
const event = { checkInWindow: 'whole_day' };
|
||||
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
|
||||
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-11T09:00:00Z')).error).toMatch(/This ticket is for Session 3 \(2026-11-18\)/);
|
||||
expect(ticketScanSession(event, COURSE, 's3', 's2', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only for Session 3/);
|
||||
expect(ticketScanSession(event, COURSE, null, null, new Date('2026-11-11T09:00:00Z')).session.id).toBe('s2');
|
||||
});
|
||||
|
||||
test('groupTickets puts one booking\'s picked tickets together', () => {
|
||||
const t = (id, sessionId, registrationId = 'r1', eventOptionId = 'drop') => ({ id, registrationOption: { registrationId, eventOptionId, variantId: null, sessionId } });
|
||||
const groups = groupTickets([t('a', 's1'), t('b', null, 'r1', 'full'), t('c', 's2'), t('d', 's1', 'r2')]);
|
||||
expect(groups.map(g => g.tickets.map(x => x.id))).toEqual([['a', 'c'], ['b'], ['d']]);
|
||||
expect(groups[0].sessionIds).toEqual(['s1', 's2']);
|
||||
expect(groups[1].sessionIds).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,3 +29,63 @@ describe('pickOffers', () => {
|
||||
expect(pickOffers(q(3, 4), 2, false)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const { lineOpen, offerExpiry, lineName } = require('../src/utils/waitlist');
|
||||
const { sessionPlacesFree } = require('../src/utils/sessions');
|
||||
|
||||
const at = (h) => new Date(Date.UTC(2026, 10, 4, h));
|
||||
const sessions = [
|
||||
{ id: 's1', startsAt: at(8), endsAt: at(9), capacity: 10, cancelledAt: null },
|
||||
{ id: 's2', startsAt: at(10), endsAt: at(11), capacity: 4, cancelledAt: null },
|
||||
{ id: 's3', startsAt: at(12), endsAt: at(13), capacity: null, cancelledAt: null },
|
||||
{ id: 's4', startsAt: at(14), endsAt: at(15), capacity: 1, cancelledAt: at(1) },
|
||||
];
|
||||
|
||||
describe('sessionPlacesFree', () => {
|
||||
const booked = new Map([['s1', 7], ['s2', 4], ['s3', 50]]);
|
||||
test('a picked session: its own capacity', () => {
|
||||
expect(sessionPlacesFree(sessions, booked, 's1')).toBe(3);
|
||||
expect(sessionPlacesFree(sessions, booked, 's2')).toBe(0);
|
||||
});
|
||||
test('a session without a capacity is unlimited', () => {
|
||||
expect(sessionPlacesFree(sessions, booked, 's3')).toBeNull();
|
||||
});
|
||||
test('whole-course: the fullest limited session, cancelled ones ignored', () => {
|
||||
expect(sessionPlacesFree(sessions, booked, null)).toBe(0);
|
||||
expect(sessionPlacesFree(sessions, new Map([['s1', 2], ['s2', 1]]), null)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lineOpen', () => {
|
||||
const event = { scheduleType: 'sessions', attendanceMode: 'pick', endDate: at(13), sessions };
|
||||
test('a session can be offered until it starts', () => {
|
||||
expect(lineOpen(event, 's2', at(9))).toBe(true);
|
||||
expect(lineOpen(event, 's2', at(10))).toBe(false);
|
||||
});
|
||||
test('cancelled or unknown sessions are not offered', () => {
|
||||
expect(lineOpen(event, 's4', at(1))).toBe(false);
|
||||
expect(lineOpen(event, 'nope', at(1))).toBe(false);
|
||||
});
|
||||
test('whole-course lines close when the course does', () => {
|
||||
expect(lineOpen(event, null, at(7))).toBe(true);
|
||||
expect(lineOpen(event, null, at(8))).toBe(false);
|
||||
expect(lineOpen({ scheduleType: 'single' }, null, at(20))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offerExpiry', () => {
|
||||
test('the offer hours, but never after the session starts', () => {
|
||||
expect(offerExpiry(24, null, at(0))).toEqual(new Date(at(0).getTime() + 24 * 3600 * 1000));
|
||||
expect(offerExpiry(24, sessions[1], at(0))).toEqual(at(10));
|
||||
expect(offerExpiry(2, sessions[1], at(0))).toEqual(at(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('lineName', () => {
|
||||
test('names the session for picked lines', () => {
|
||||
expect(lineName({ eventOption: { name: 'Drop-in' }, variant: null, session: null })).toBe('Drop-in');
|
||||
expect(lineName({ eventOption: { name: 'Drop-in' }, variant: { name: 'Adult' }, session: { ...sessions[1], label: 'Class 2' } }, sessions))
|
||||
.toBe('Drop-in (Adult) — Class 2, Wed 4 Nov');
|
||||
expect(lineName({ eventOption: { name: 'Drop-in' }, session: sessions[1] }, sessions)).toBe('Drop-in — Session 2, Wed 4 Nov');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
# Hope Events — New Features
|
||||
|
||||
Planned features for the next round of development. Each section covers what
|
||||
the feature does, who uses it, how it fits the existing platform, and the edge
|
||||
cases to handle. See [FEATURES.md](./FEATURES.md) for what already exists.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Coupon codes](#1-coupon-codes)
|
||||
2. [Waitlist](#2-waitlist)
|
||||
3. [Group tickets](#3-group-tickets)
|
||||
4. [Offline scanning](#4-offline-scanning)
|
||||
5. [Parental consent (youth events)](#5-parental-consent-youth-events)
|
||||
6. [Sponsor a spot](#6-sponsor-a-spot)
|
||||
7. [Ticket transfer](#7-ticket-transfer)
|
||||
8. [Post-event feedback form](#8-post-event-feedback-form)
|
||||
9. [Group / room allocation](#9-group--room-allocation)
|
||||
10. [Registration funnel stats](#10-registration-funnel-stats)
|
||||
11. [2FA for Admins and Supervisors](#11-2fa-for-admins-and-supervisors)
|
||||
12. [Admin "impersonate user"](#12-admin-impersonate-user)
|
||||
13. [Suggested build order](#suggested-build-order)
|
||||
|
||||
---
|
||||
|
||||
## 1. Coupon codes
|
||||
|
||||
Discount codes applied at registration. The main use case is **staff
|
||||
registering for events** at a reduced or free rate, but codes also work for
|
||||
promos, leaders, and volunteers.
|
||||
|
||||
**Who:** Supervisors create and manage codes. Users enter them at checkout.
|
||||
Supervisors can also apply them in Manual registration, At the door and the
|
||||
kiosk.
|
||||
|
||||
### Code settings
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Code | Unique text, case-insensitive (e.g. `STAFF50`) |
|
||||
| Discount type | Percentage · Fixed amount · Comp (100% free) |
|
||||
| Scope | Whole event · Specific ticket option(s) · Specific variant(s) |
|
||||
| Events | One event, several events, or all events |
|
||||
| Valid from / until | Optional date window |
|
||||
| Max total uses | Optional |
|
||||
| Max uses per user | Optional (default 1) |
|
||||
| Min quantity | Optional (e.g. 4+ tickets) |
|
||||
| Restricted to | Anyone · Specific users · Roles (e.g. Staff and above) · Email domain |
|
||||
| Stack with early-bird | Yes (apply on top of early-bird price) / No (best price wins) |
|
||||
| Active | On / off toggle |
|
||||
|
||||
### How it works
|
||||
|
||||
- The user enters a code on the registration page. It is validated server-side
|
||||
and the discount shows as its own line before payment.
|
||||
- The discount is stored as a **separate discount line on the registration**,
|
||||
with its value **locked at apply time**, the same way early-bird tranches
|
||||
are locked. Pricing refresh never re-prices an applied coupon, even if the
|
||||
code later expires or is disabled.
|
||||
- **Restricted codes** (by user, role or domain) need the user to be logged
|
||||
in. Guest registration can't use them. This stops staff codes leaking in
|
||||
group chats.
|
||||
- **Role-based staff pricing (optional):** a ticket option can also carry a
|
||||
"Staff price" that applies automatically for Staff+ users without a code.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Edit options:** percentage codes recalculate over the new item total.
|
||||
Fixed-amount codes stay fixed. Comp codes cover only in-scope options.
|
||||
- **Cancellation / refund:** a cancelled registration releases its use count.
|
||||
- **Discount can't go below R0.** A comp code on a fully covered registration
|
||||
marks it Paid and generates tickets.
|
||||
- **Concurrency:** check use limits inside a transaction so the last use
|
||||
can't be claimed twice.
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Invoices and receipts show the code and discount line.
|
||||
- **New report: Discounts.** Uses per code, total value given away, per event.
|
||||
- Finance and profit reports subtract discounts from expected revenue.
|
||||
- **Audit log:** code created, edited, disabled, and manually applied by staff.
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
model Coupon {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
type CouponType // PERCENT | FIXED | COMP
|
||||
value Decimal?
|
||||
stackEarlyBird Boolean @default(false)
|
||||
validFrom DateTime?
|
||||
validUntil DateTime?
|
||||
maxUses Int?
|
||||
maxUsesPerUser Int? @default(1)
|
||||
minQuantity Int?
|
||||
restrictRoles Role[]
|
||||
restrictDomain String?
|
||||
active Boolean @default(true)
|
||||
events CouponEvent[]
|
||||
options CouponOption[]
|
||||
allowedUsers CouponUser[]
|
||||
redemptions CouponRedemption[]
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model CouponRedemption {
|
||||
id String @id @default(cuid())
|
||||
couponId String
|
||||
registrationId String
|
||||
userId String
|
||||
discountAmount Decimal // locked at apply time
|
||||
appliedById String? // staff member if applied manually
|
||||
createdAt DateTime @default(now())
|
||||
releasedAt DateTime? // set on cancellation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Waitlist
|
||||
|
||||
When an event or ticket option is sold out, users can join a waitlist instead
|
||||
of hitting a dead end.
|
||||
|
||||
**Who:** Users join. Supervisors view and manage the queue.
|
||||
|
||||
### How it works
|
||||
|
||||
- Sold-out options show a **"Join waitlist"** button instead of "Sold Out".
|
||||
- The user picks an option, variant and quantity. Login is required if the
|
||||
event requires login. Otherwise a guest account is created as usual.
|
||||
- When stock frees up (a cancellation, a refund, or a supervisor raising the
|
||||
stock limit), the **next person in line** gets an email/WhatsApp with a
|
||||
**claim link**.
|
||||
- The claim link **reserves the stock for a set time** (default 24 h, set per
|
||||
event). If it isn't used in time, the offer moves to the next person.
|
||||
- A claimed offer goes through normal registration and payment, with
|
||||
early-bird and coupon rules applied as usual.
|
||||
|
||||
### Supervisor tools
|
||||
|
||||
- A waitlist tab per event shows position, person, option, quantity, joined
|
||||
date and offer status.
|
||||
- Supervisors can manually offer a spot to someone, reorder the queue, or
|
||||
remove an entry.
|
||||
- Supervisors can toggle the waitlist on or off per event.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Reserved-but-unclaimed stock counts as sold until the offer expires, so the
|
||||
public site doesn't oversell.
|
||||
- If an offer is for quantity 3 and only 2 free up, offer the 2 with a note,
|
||||
or skip to the next person who fits. This is configurable per event.
|
||||
- Users can leave the waitlist from their dashboard.
|
||||
- Add the waitlist to the Daily summary (count per event).
|
||||
|
||||
---
|
||||
|
||||
## 3. Group tickets
|
||||
|
||||
One payer registers **several named attendees**, each with their own ticket
|
||||
and their own form response. For example, a parent registers three kids for
|
||||
camp.
|
||||
|
||||
**This is optional.** The payer can still choose to receive all tickets
|
||||
themselves.
|
||||
|
||||
### How it works
|
||||
|
||||
- On the registration page, a toggle reads **"Registering for other people?"**
|
||||
- For each attendee: name, plus optional email/phone, plus the event form if
|
||||
there is one.
|
||||
- **Ticket delivery choice per registration:**
|
||||
- **Send all tickets to me** (default), so the payer gets every ticket.
|
||||
- **Send each person their own ticket**, using their email/WhatsApp. If an
|
||||
attendee has no contact details, their ticket falls back to the payer.
|
||||
- Tickets are **individual, named tickets** rather than a quantity ticket, so
|
||||
each one can be checked in, transferred or allocated to a room/group
|
||||
separately.
|
||||
- The payer stays the account owner for billing, invoices and payments.
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Works alongside the existing quantity tickets, since an event can allow
|
||||
either.
|
||||
- If an attendee's email/phone matches an existing account, link the ticket
|
||||
to that account (with the same duplicate-account protection as today).
|
||||
- Attendee forms: each attendee has their own response. The payer can fill
|
||||
them in, or each attendee can get a link to fill in their own.
|
||||
- Feeds into parental consent (one consent request per minor attendee) and
|
||||
group/room allocation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Offline scanning
|
||||
|
||||
Lets the scanner keep working when venue Wi-Fi or mobile data drops.
|
||||
|
||||
> ⚠️ **Rules: offline mode is per section, and only one device can scan a
|
||||
> section offline at a time.**
|
||||
> While offline, devices can't see each other's scans. If two devices scanned
|
||||
> the same section offline, the same ticket could be redeemed twice. Locking
|
||||
> each section to a single device removes that risk without needing conflict
|
||||
> resolution.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Before going offline (while online):** a staff member picks an event and
|
||||
section and taps **"Enable offline mode"**.
|
||||
2. The server **locks that section to this device** and records the device ID
|
||||
and the staff member. Other devices see "Offline-locked by [name] on [device]"
|
||||
and can't scan that section, online or offline, until it's released.
|
||||
3. The device **downloads a snapshot** of every ticket valid for that section
|
||||
(ticket ID, holder name, type, quantity remaining) into IndexedDB.
|
||||
4. **While offline:** scans validate against the local snapshot and update
|
||||
remaining quantities locally. Every scan is queued with a timestamp.
|
||||
5. **When back online:** queued scans sync to the server automatically. Any
|
||||
conflicts (for example, a ticket transferred or cancelled while offline)
|
||||
show in a **sync report** for a supervisor to review.
|
||||
6. Staff tap **"Release section"** to end offline mode and unlock the section.
|
||||
|
||||
### Rules and edge cases
|
||||
|
||||
- One section can only be locked to one device at a time. Other sections of
|
||||
the same event can be locked to other devices.
|
||||
- A supervisor can **force-release** a lock if a phone dies or goes missing.
|
||||
Unsynced scans on that device are lost unless the device reconnects later,
|
||||
in which case they sync and are flagged.
|
||||
- **Snapshot age:** show "Ticket list downloaded at 17:42". Tickets bought
|
||||
after that time won't be on the device, and the scanner shows "Not found in
|
||||
offline list. Check at the door desk."
|
||||
- The snapshot is stored only for the lock's duration and cleared on release.
|
||||
- Check-in confirmation messages to holders are sent after sync, not
|
||||
instantly.
|
||||
- **Audit log:** lock, release, force-release, and sync with scan count.
|
||||
- Built as a **PWA** (service worker caches the scanner page and assets).
|
||||
|
||||
---
|
||||
|
||||
## 5. Parental consent (youth events)
|
||||
|
||||
For youth events, a parent or guardian must give consent before an attendee
|
||||
under 18 can attend. The parent gets an **email and a WhatsApp** with a link
|
||||
to review and give consent.
|
||||
|
||||
### Event setup
|
||||
|
||||
- New event setting: **"Youth event: requires parental consent"**.
|
||||
- Supervisors write the **consent text** (indemnity, activities, photo
|
||||
permission, transport, medical treatment authorisation) in the event wizard.
|
||||
- Optional extra sections on the consent page: **medical info** (allergies,
|
||||
medication, conditions, medical aid), **emergency contact**, and **photo
|
||||
consent** (Yes/No, recorded separately).
|
||||
|
||||
### How it works
|
||||
|
||||
1. During registration, each attendee gives their **date of birth**. If they
|
||||
are under 18, **parent/guardian name, email and phone** are required.
|
||||
2. On registration, the parent gets an **email and a WhatsApp** (always both,
|
||||
regardless of anyone's notification preference) with a unique, secure
|
||||
consent link.
|
||||
3. The consent page shows the event details, attendee name and consent text.
|
||||
The parent fills in any medical/emergency fields, **types their full name**
|
||||
as a signature, ticks "I am the parent/legal guardian", and taps
|
||||
**Give consent** (or **Decline**).
|
||||
4. The system records the timestamp, IP address, device, typed name and the
|
||||
**version of the consent text** they agreed to.
|
||||
5. The registrant and the event's notify recipients get a confirmation.
|
||||
|
||||
### Rules
|
||||
|
||||
- **Tickets are generated but marked "Consent pending"** until consent is
|
||||
given. Scanners show a clear warning and block check-in for that ticket
|
||||
(supervisors can override, with an audit log entry).
|
||||
- **Reminders:** automatic reminder email/WhatsApp after 2 days, then every
|
||||
3 days until the event, or until consent is given or declined.
|
||||
- **Declined consent:** notifies the registrant and supervisors. The
|
||||
registration stays but can't be checked in. A supervisor handles the refund.
|
||||
- **Consent links expire** at the event's end date, and can be resent from
|
||||
the registration detail screen.
|
||||
- If the consent text is edited after some parents have consented, they keep
|
||||
their record of the old version. Supervisors can choose to re-request
|
||||
consent.
|
||||
|
||||
### Supervisor tools
|
||||
|
||||
- **Consent status column** on registrations and At the door (Pending /
|
||||
Given / Declined).
|
||||
- **New reports:** Consent status per event, and **Medical & emergency list**
|
||||
(printable, for camp leaders). Access to the medical list is restricted to
|
||||
Supervisor+ and logged.
|
||||
- Bulk "Resend consent request" to everyone still pending.
|
||||
|
||||
### Privacy
|
||||
|
||||
Under POPIA, processing a child's personal information needs the consent of a
|
||||
competent person (parent/guardian). Medical data is special personal
|
||||
information. Store it encrypted, restrict access, include it in the existing
|
||||
anonymise/delete flows, and delete it a set period after the event.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sponsor a spot
|
||||
|
||||
A public donation flow linked to a specific event, so people can fund a spot
|
||||
for someone who can't afford it (bursaries).
|
||||
|
||||
### How it works
|
||||
|
||||
- A new event setting: **"Allow sponsorships"**. This adds a **"Sponsor a
|
||||
spot"** button on the event page.
|
||||
- Sponsors choose: **one full spot** (the main ticket price), **multiple
|
||||
spots**, or **a custom amount**. Payment goes through Yoco as usual.
|
||||
- A sponsor can be anonymous, or add a name and message.
|
||||
- The money is recorded as a **donation tagged to the event**, and lands in a
|
||||
**sponsorship pool** for that event.
|
||||
- Supervisors assign pool funds to registrations using the existing donation
|
||||
assign/unassign flow, fully or partly.
|
||||
- An optional public progress bar shows "12 of 20 spots sponsored".
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Sponsors get a receipt. They can optionally get a thank-you after the event
|
||||
through Automations.
|
||||
- The Donations breakdown report gains a sponsorship pool column (received,
|
||||
assigned, remaining) per event.
|
||||
- Recipients' identities are never shown to sponsors.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ticket transfer
|
||||
|
||||
Users can hand their ticket to someone else before the event.
|
||||
|
||||
### How it works
|
||||
|
||||
- From My Events, a user taps **Transfer** on a ticket, and enters the new
|
||||
holder's name and email/phone.
|
||||
- The new holder gets the ticket by email/WhatsApp. If they don't have an
|
||||
account, an inactive one is created with an activation link (same as guest
|
||||
registration).
|
||||
- The **old QR code is invalidated** and a new one is issued, so screenshots
|
||||
of the old ticket won't scan.
|
||||
- The original payer stays responsible for the registration (billing doesn't
|
||||
move).
|
||||
|
||||
### Rules
|
||||
|
||||
- Allowed only before the event starts, and only for unredeemed tickets
|
||||
(partially redeemed quantity tickets can't be transferred).
|
||||
- Can be disabled per event. Supervisors can always transfer.
|
||||
- If the event needs a form or parental consent, the new holder must complete
|
||||
their own.
|
||||
- **Audit log:** transfer from, to, by whom and when. Offline scanning sync
|
||||
flags any scan of a transferred ticket's old code.
|
||||
|
||||
---
|
||||
|
||||
## 8. Post-event feedback form
|
||||
|
||||
An automatic survey sent after the event.
|
||||
|
||||
### How it works
|
||||
|
||||
- In the event wizard, a new **Feedback** step reuses the form builder, plus
|
||||
new field types: **Rating (1–5 stars)** and **Multiple choice**.
|
||||
- Supervisors pick a **send time** (e.g. the morning after the event ends).
|
||||
It's sent by the existing scheduled job to everyone who **checked in**, or
|
||||
to all registrants, as chosen.
|
||||
- Responses can be anonymous or named (per event setting).
|
||||
- One automatic reminder after 3 days for anyone who hasn't responded.
|
||||
|
||||
### Reporting
|
||||
|
||||
- **New report: Feedback summary.** Response rate, average rating per
|
||||
question, multiple-choice breakdown (bar charts), and a list of text
|
||||
answers. Exports to PDF/Excel like other reports.
|
||||
- Ratings can be compared across events (e.g. Camp 2025 vs Camp 2026).
|
||||
|
||||
---
|
||||
|
||||
## 9. Group / room allocation
|
||||
|
||||
Assign attendees to cabins, rooms, small groups, teams or buses. Built for
|
||||
camps and lockdowns.
|
||||
|
||||
### How it works
|
||||
|
||||
- Per event, supervisors create **allocation sets**, e.g. "Rooms", "Small
|
||||
groups", "Buses". An event can have several sets.
|
||||
- Each set has **groups** with an optional **capacity**, an optional
|
||||
**gender restriction** and an optional **leader** (a staff user).
|
||||
- **Drag-and-drop** board: unassigned attendees on the left, groups on the
|
||||
right, with capacity counters.
|
||||
- **Auto-allocate:** fill groups evenly, respecting capacity and gender, and
|
||||
optionally keeping registrations together (siblings) or splitting them
|
||||
(friends).
|
||||
- Works per attendee, so it relies on group tickets (named attendees).
|
||||
|
||||
### Outputs
|
||||
|
||||
- Printable lists per group (with leader), and a master list.
|
||||
- Optionally show the group/room on the attendee's ticket and in My Events.
|
||||
- Group leaders (staff) can see their group's list, including medical
|
||||
info if they're allowed to.
|
||||
- Bulk message by group ("Bus 2 leaves at 07:30").
|
||||
|
||||
---
|
||||
|
||||
## 10. Registration funnel stats
|
||||
|
||||
Shows where people drop off between seeing an event and paying.
|
||||
|
||||
### Funnel stages (per event)
|
||||
|
||||
1. **Event page views** (unique visitors)
|
||||
2. **Registration started** (opened the registration page)
|
||||
3. **Registration submitted**
|
||||
4. **Paid** (fully paid or free)
|
||||
|
||||
### How it works
|
||||
|
||||
- Lightweight first-party tracking (no third-party analytics). Log page views
|
||||
and stage events with an anonymous session ID. No personal data until the
|
||||
user logs in or registers.
|
||||
- **New report: Registration funnel.** Count and conversion % at each stage,
|
||||
over a date range, with a daily trend chart.
|
||||
- Optional breakdowns: by traffic source (UTM tags or short link alias, e.g.
|
||||
`/camp-2025` vs direct), by device (mobile/desktop), and by ticket option.
|
||||
- A dashboard tile shows conversion % for active events.
|
||||
|
||||
### Privacy
|
||||
|
||||
Respect cookie consent. Aggregate raw page-view data after 90 days.
|
||||
|
||||
---
|
||||
|
||||
## 11. 2FA for Admins and Supervisors
|
||||
|
||||
Two-factor authentication, **required** for Admin and Supervisor accounts.
|
||||
|
||||
### How it works
|
||||
|
||||
- **Method:** authenticator app (TOTP, e.g. Google Authenticator, Microsoft
|
||||
Authenticator). WhatsApp code can be offered as a backup method since WAWP
|
||||
is already set up.
|
||||
- **Enrolment:** on the next login after being given the Supervisor/Admin
|
||||
role, the user must set up 2FA by scanning a QR code and confirming a code.
|
||||
- **Recovery codes:** 10 one-time codes shown once at setup, downloadable.
|
||||
- **Remember this device** for 30 days (optional, per login).
|
||||
- Optional for Staff and User roles, from Profile & Security.
|
||||
|
||||
### Admin controls
|
||||
|
||||
- Admins can **reset another user's 2FA** (e.g. a lost phone). This revokes
|
||||
their sessions and forces re-enrolment.
|
||||
- Security event log entries: 2FA enabled, disabled, reset, recovery code
|
||||
used, failed code attempts.
|
||||
- Failed 2FA attempts count toward the existing login lockout.
|
||||
- The setup wizard enrols the first admin in 2FA.
|
||||
|
||||
---
|
||||
|
||||
## 12. Admin "impersonate user"
|
||||
|
||||
Lets an Admin see the site exactly as a specific user sees it, to debug
|
||||
problems ("my ticket isn't showing").
|
||||
|
||||
### How it works
|
||||
|
||||
- From Manage users, an Admin taps **Impersonate** on a user and must enter a
|
||||
**reason** (required).
|
||||
- A short-lived session is created (max 30 minutes) that acts as the user.
|
||||
- A **bright banner on every page**: "Viewing as [Name]. [End impersonation]".
|
||||
|
||||
### Safety rules
|
||||
|
||||
- **Read-only by default.** Payments, refunds, cancellations, password/email
|
||||
changes, deleting data, giving consent and sending messages are blocked
|
||||
while impersonating.
|
||||
- Can't impersonate another Admin.
|
||||
- Requires 2FA to be active on the admin's account.
|
||||
- **Audit log:** start, end, reason, and every page/action during the
|
||||
session.
|
||||
- Optional: notify the user that an admin viewed their account (an
|
||||
organisation-level setting).
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Features | Why |
|
||||
|-------|----------|-----|
|
||||
| 1 | 2FA, Coupon codes | Security first. Coupons are self-contained and give an immediate win for staff. |
|
||||
| 2 | Group tickets | Foundation for consent, transfer and room allocation (named attendees). |
|
||||
| 3 | Parental consent, Waitlist | The biggest wins for youth events. |
|
||||
| 4 | Offline scanning, Ticket transfer | Door-day reliability. Transfer must exist before offline sync handles it. |
|
||||
| 5 | Group/room allocation, Sponsor a spot | Camp-specific and bursary support. |
|
||||
| 6 | Feedback form, Funnel stats, Impersonate user | Insights and support tooling. |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
audit log entries, a CHANGELOG entry, and updates to FEATURES.md and
|
||||
SETTINGS.md.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-23, after **1.35.1**. The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
## Done so far (road to 2.0)
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.32.0 | Early-bird ribbon on event cards |
|
||||
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
|
||||
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
|
||||
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
|
||||
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
|
||||
|
||||
## Next up
|
||||
|
||||
1. **1.36.0 — payment provider interface.** Move Yoco behind a `PaymentProvider`
|
||||
interface (`createCheckout`, `handleWebhook`, `getTransaction`, `refund`,
|
||||
`listTransactions`) with no behaviour change; generalise `YocoTransaction` to a
|
||||
provider transaction table; Site settings → Payments with an Enabled toggle per
|
||||
provider and display order. Decisions: fees are **never** deducted or passed on
|
||||
(people handle fees at cashup); minimum online payment **R15** for all providers.
|
||||
2. **1.37.0 PayFast → 1.38.0 SnapScan → 1.39.0 Zapper** (one release each). Get the
|
||||
current API documentation links from Joshua before building each one, and don't
|
||||
guess at an API — sandbox/test credentials needed for SnapScan and Zapper.
|
||||
3. **1.40.0–1.41.0 — POPIA toolkit** (spec §3 in the roadmap).
|
||||
4. **1.42.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
`joshua/hope-events-hub`).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
- **Release routine:** branch `feat/<name>` → commit → bump with
|
||||
`npm version X.Y.Z --no-git-tag-version` in `.`, `backend` and `frontend` → add the
|
||||
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
|
||||
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
|
||||
version per feature.
|
||||
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
|
||||
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
|
||||
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
|
||||
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
|
||||
- **Prisma migrations:** `npx prisma migrate dev --create-only --name x` against a local
|
||||
DB. Prisma 7 keeps adding an unrelated `_EventNotifyRecipients` primary-key/index
|
||||
change — delete those two blocks from the generated SQL. It also rewrites the comment
|
||||
in `migration_lock.toml`; `git checkout` that file back. New `AdminAuditAction` values
|
||||
need a migration.
|
||||
- **Server Node version:** the deployment notes install Node 20, so dependencies must
|
||||
support Node 20 (that's why pg-boss is pinned to 10.x).
|
||||
- **Pushing:** pushing from some shells fails with "Failed to authenticate user" (a stale
|
||||
saved credential); pushing from your own terminal works.
|
||||
|
||||
## Tests
|
||||
|
||||
- Backend unit tests: `cd backend && npm test` (Jest).
|
||||
- End-to-end: `npm run test:e2e` from the repo root (first time: `npm install` and
|
||||
`npx playwright install chromium`). They start their own backend (5100) and
|
||||
frontend (3100) against `<your DATABASE_URL database>_e2e`, which is dropped and
|
||||
re-seeded every run (`e2e/support/prepare-db.js`). The DB user must be able to
|
||||
create databases. ~5 minutes in dev mode.
|
||||
Locally, Playwright reuses servers already running on 5100/3100. If the run times out
|
||||
waiting for the frontend (the home page returns 500 until the e2e backend is up), start
|
||||
them yourself — `node e2e/support/prepare-db.js`, then the backend from `backend/`
|
||||
with `PORT=5100`, the e2e `DATABASE_URL`, `JOBS_ENABLED=false`,
|
||||
`TWO_FACTOR_REQUIRED_ROLES=false`, and `npx next dev -p 3100` from `frontend/` with
|
||||
`NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_API_URL=http://localhost:5100` — then run
|
||||
`npx playwright test`. Don't edit frontend files while it runs.
|
||||
- **Careful with ad-hoc scripts against a dev/test database that has SMTP set up:**
|
||||
registrations and check-ins send real email/WhatsApp. Use made-up `@test.local`
|
||||
people only.
|
||||
- CI: `.gitea/workflows/ci.yml` runs everything once a Gitea Actions runner is
|
||||
registered (none yet). Tag deploys need the `DEPLOY_HOST`, `DEPLOY_USER`,
|
||||
`DEPLOY_SSH_KEY` (+ optional `DEPLOY_PATH`) secrets.
|
||||
|
||||
## Where the multi-day code lives
|
||||
|
||||
- `backend/src/utils/sessions.js` — all session rules (dates, check-in window, late
|
||||
pricing, pick sessions, capacity, grouping); unit tests in `backend/tests/sessions.test.js`.
|
||||
- `backend/src/utils/certificates.js`, `sessionReminders.js`,
|
||||
`controllers/attendanceController.js` (register + certificates API).
|
||||
- `backend/src/jobs/index.js` — the background job registry.
|
||||
- Frontend: `src/lib/sessions.ts`, `src/components/events/ScheduleEditor.tsx`,
|
||||
`SessionPicker.tsx`, `SessionSchedule.tsx`, `src/app/dashboard/supervisor/attendance/`.
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- At the door, per-session tickets are booked on new registrations only; editing an
|
||||
existing registration leaves its booked sessions as they are (the backend refuses
|
||||
changes to picked lines). "Add & charge" for a session at the scanner isn't built.
|
||||
- Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no
|
||||
camera); scans are tested through the API.
|
||||
@@ -0,0 +1,775 @@
|
||||
# Hope Events — Road to 2.0.0
|
||||
|
||||
Planned features and platform work to take Hope Events from 1.32.x to 2.0.0.
|
||||
Each section covers what the feature does, who uses it, how it fits the
|
||||
existing platform, and the edge cases to handle. See [FEATURES.md](../FEATURES.md)
|
||||
for what already exists, and [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md) for the
|
||||
previous round (1.12.0 – 1.23.0). Where to pick up: [HANDOVER.md](./HANDOVER.md).
|
||||
|
||||
**Theme for 2.0:** turn a feature-rich church app into a product that can be
|
||||
sold — trustworthy (POPIA), flexible (multi-day events, several payment
|
||||
providers), and maintainable (tests, CI, typed backend, versioned API).
|
||||
|
||||
**Status (2026-10-23):** all features in this document are approved. Shipped
|
||||
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
|
||||
phase A (sessions, late joining, per-session check-in, attendance register,
|
||||
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
|
||||
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
|
||||
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
|
||||
picker at the door and session choice on "All sessions" tickets. **Next: 1.36.0 payment provider interface (Yoco
|
||||
moved behind it), then PayFast, SnapScan, Zapper.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
| Repo | What |
|
||||
|------|------|
|
||||
| `joshua/hope-events` (this repo) | The church app. Everything in this document except the Hub app itself — including the Hub *client* (licence step, "Connect to Hub", heartbeat, licence checks) |
|
||||
| `joshua/hope-events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
|
||||
2. [Payment providers: PayFast, SnapScan, Zapper](#2-payment-providers-payfast-snapscan-zapper)
|
||||
3. [POPIA toolkit](#3-popia-toolkit)
|
||||
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
|
||||
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
|
||||
6. [Event series and cloning](#6-event-series-and-cloning)
|
||||
7. [Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)
|
||||
8. [Outbound webhooks](#8-outbound-webhooks)
|
||||
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
|
||||
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
|
||||
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
|
||||
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
|
||||
13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub)
|
||||
14. [Foundations (platform work)](#14-foundations-platform-work)
|
||||
15. [Suggested build order](#suggested-build-order)
|
||||
16. [Decisions](#decisions-2026-10-22)
|
||||
|
||||
---
|
||||
|
||||
## 1. Multi-day events (sessions)
|
||||
|
||||
One event that runs over several dates, e.g. **"Alpha Course — every Wednesday
|
||||
18:30–20:30 for 5 weeks"**, a 3-day conference, or a camp with an evening
|
||||
session each day. People register and pay **once**, get **one ticket**, and are
|
||||
checked in **per session**.
|
||||
|
||||
> Not the same as [Event series](#6-event-series-and-cloning). A series groups
|
||||
> *separate* events (Camp 2026, Camp 2027) that each have their own
|
||||
> registrations. Sessions are dates *inside one* event.
|
||||
|
||||
**Who:** Supervisors set up the schedule. Users see it on the event page, in
|
||||
their tickets and in their calendar. Staff check in per session.
|
||||
|
||||
### Event setup
|
||||
|
||||
New **Schedule** section on the event form:
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Schedule type | Single date (today's behaviour) · Multiple sessions |
|
||||
| Pattern builder | Repeat **daily / weekly / every N weeks** on chosen weekday(s), start date, time from–to, **number of sessions** or **until date** |
|
||||
| Generated sessions | Editable list: change date/time/location/label of any one session, delete, or add an extra one-off date |
|
||||
| Session label | Optional, e.g. "Week 1 — Is there more to life?" (defaults to "Session 1, 2, …") |
|
||||
| Session location | Optional; falls back to the event location |
|
||||
| Attendance mode | **Whole course** (one registration covers all sessions) · **Pick sessions** (see phase B below) |
|
||||
| Late joining | Per event: Off (registration closes when the first session starts) · Allow until the start of session N |
|
||||
| Late-joiner price | Per event, only when late joining is on: Full price · **Pro-rata** (price × remaining sessions ÷ total sessions, rounded to the **nearest R10**, e.g. R183 → R180, R185 → R190) · Fixed late price |
|
||||
| Check-in window | Per event: **Whole day** (default — any time on the session's date, in case someone was let in without being scanned) · Session time only (2 h before start until the session ends) |
|
||||
| Session reminders | Per event, **off by default**: when on, choose timing (e.g. 3 h before, the day before at 18:00, or both) and channels (email / WhatsApp) |
|
||||
| Certificates | Per event, **off by default**: when on, choose a template and the completion rule (see [Certificates](#certificates)) |
|
||||
|
||||
The pattern builder is only a helper: it generates rows, then the session list
|
||||
is the source of truth. Typing "every Wednesday for 5 weeks" and then skipping
|
||||
a public holiday is just deleting one row.
|
||||
|
||||
### How it works
|
||||
|
||||
- `Event.startDate` / `endDate` **stay** and are kept in sync automatically
|
||||
(first session start, last session end). Everything that already reads them —
|
||||
consent age checks, transfer cut-off, early-bird, feedback timing, ICS,
|
||||
listings, sitemap — keeps working unchanged. Only places that need
|
||||
per-session behaviour are updated.
|
||||
- **Public event page:** shows the full schedule, with past sessions greyed out
|
||||
and a "Next session" highlight. Event cards show "5 sessions · Wed 4 Nov –
|
||||
Wed 2 Dec".
|
||||
- **Event listings:** a multi-session event stays "upcoming / running" until
|
||||
its **last** session ends, and sorts by its **next** session.
|
||||
- **Tickets (PDF, email, WhatsApp):** list all session dates. One QR code.
|
||||
- **Calendar (ICS):** one VEVENT per session (not an RRULE, so edited or
|
||||
skipped sessions are exact).
|
||||
- **Check-in:** the scanner auto-selects today's session (on a day with more
|
||||
than one session, the one happening now or next), with a dropdown to
|
||||
override. Whether a scan is accepted depends on the event's **check-in
|
||||
window** (whole day or session time only). A ticket can be checked in
|
||||
**once per session** (per person for group tickets). Scanning again in the
|
||||
same session shows "Already checked in for Session 3 at 18:42".
|
||||
- **Manual check-in:** supervisors can tick someone as present for a session
|
||||
from the attendance register afterwards (e.g. let in without a scan), logged
|
||||
in the audit log with who marked it.
|
||||
- **Reminders:** off by default; when switched on for the event, a reminder
|
||||
goes out before **each** session (email/WhatsApp, via the scheduled-message
|
||||
worker), e.g. "See you tonight at 18:30".
|
||||
- **Late joiners:** when late joining is on, the registration page shows which
|
||||
sessions are left and the late price ("3 of 5 sessions left — R180").
|
||||
- **Changes:** moving or cancelling a session after people registered prompts
|
||||
the supervisor to notify registrants (pre-filled broadcast), and updates the
|
||||
ICS/tickets.
|
||||
|
||||
### Phase B: pick sessions (drop-in)
|
||||
|
||||
Second step, released separately. Lets people register for **some** sessions,
|
||||
e.g. a conference where each day is sold separately or a weekly drop-in class.
|
||||
|
||||
- Ticket options can be scoped to sessions: "Day pass (pick days)" priced per
|
||||
session, "Full conference pass" covering all.
|
||||
- Optional **capacity per session** on top of the option stock limit.
|
||||
- The ticket stores which sessions it covers; scanning on a session it doesn't
|
||||
cover shows "Not registered for this session" (with "Add & charge" at the
|
||||
door for supervisors).
|
||||
- Waitlist works per session when sessions have their own capacity.
|
||||
|
||||
### Reporting
|
||||
|
||||
- **Attendance register:** grid of attendees × sessions with ticks, totals per
|
||||
session, attendance % per person. Export to Excel and PDF.
|
||||
- **Completion filter:** "attended at least 4 of 5" — useful for certificates
|
||||
and for follow-up ("missed the last 2 weeks").
|
||||
- Daily summary email includes "Sessions today".
|
||||
- Funnel stats unchanged (they are per event).
|
||||
|
||||
### Certificates
|
||||
|
||||
Optional completion certificates (PDF), chosen per event.
|
||||
|
||||
- **3–4 built-in templates**, e.g. *Classic* (border, serif), *Modern* (clean,
|
||||
accent colour bar), *Church* (logo-led, scripture line), *Kids* (playful,
|
||||
for children's programmes). All use the site logo and brand colour from Site
|
||||
settings.
|
||||
- **Event settings:** Certificates on/off · Template · Title ("Certificate of
|
||||
Completion") · Body text with placeholders (`{name}`, `{event}`,
|
||||
`{sessionsAttended}`, `{totalSessions}`, `{endDate}`) · Signature name and
|
||||
role (optional uploaded signature image) · **Completion rule**: attended at
|
||||
least N sessions or N % (default: all sessions).
|
||||
- **Preview** button in the event form renders a sample PDF.
|
||||
- **Who gets one:** attendees meeting the completion rule after the last
|
||||
session. Supervisors can issue/withdraw one manually (audit logged).
|
||||
- **Delivery:** download from My Events, bulk download (ZIP) from the
|
||||
attendance register, and optional email/WhatsApp send.
|
||||
- Each certificate has a short verification code + QR linking to a public
|
||||
"certificate valid" page (name, event, date only).
|
||||
- Generated with `pdfkit`, like tickets and receipts (`utils/pdfDocs.js`).
|
||||
- Also usable on single-date events (completion = checked in).
|
||||
|
||||
### Edge cases
|
||||
|
||||
| Case | Behaviour |
|
||||
|------|-----------|
|
||||
| Delete a session that has check-ins | Blocked; offer **Cancel session** instead (kept for history, hidden from schedule, excluded from attendance %) |
|
||||
| Switch Multiple → Single after registrations | Allowed only if no session check-ins exist |
|
||||
| Ticket transfer | Allowed until the **first** session starts (current rule). Optional later: allow mid-course transfers with check-in history kept on the original holder |
|
||||
| Parental consent | Age is measured at the first session (current `startDate` rule) |
|
||||
| Medical details deletion | Runs after the **last** session (end date), as today |
|
||||
| Feedback form | Sent after the last session |
|
||||
| Cashup | Still per event. At-the-door cash per session is captured with the session on the payment so reports can split it |
|
||||
| Offline scanning | Session list and per-session usages are part of the offline lock bundle; sync keeps the session id per scan |
|
||||
| Timezones | Sessions stored as UTC instants, same as `Event.startDate` today (see `icsUtils.js`) |
|
||||
| Session overlaps / out of order | Allowed (conferences have parallel tracks later), list always sorted by start |
|
||||
| Kiosk / At the door | Shows the current session; check-in counts against it |
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
enum ScheduleType { single sessions }
|
||||
enum AttendanceMode { whole pick }
|
||||
|
||||
enum LatePricing { full prorata fixed }
|
||||
enum CheckInWindow { whole_day session_only }
|
||||
|
||||
model Event {
|
||||
// ...existing fields
|
||||
scheduleType ScheduleType @default(single)
|
||||
attendanceMode AttendanceMode @default(whole)
|
||||
lateJoinEnabled Boolean @default(false)
|
||||
lateJoinUntilSessionId String?
|
||||
latePricing LatePricing @default(full)
|
||||
lateFixedPrice Float?
|
||||
checkInWindow CheckInWindow @default(whole_day)
|
||||
sessionReminders Json? // null = off; { offsets: [...], channels: [...] }
|
||||
certificatesEnabled Boolean @default(false)
|
||||
certificateConfig Json? // template, title, body, signature, completion rule
|
||||
sessions EventSession[]
|
||||
certificates Certificate[]
|
||||
}
|
||||
|
||||
model Certificate {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
attendeeId String?
|
||||
userId String?
|
||||
name String
|
||||
code String @unique // verification code
|
||||
manual Boolean @default(false)
|
||||
issuedAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
model EventSession {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
label String?
|
||||
startsAt DateTime
|
||||
endsAt DateTime
|
||||
location String?
|
||||
capacity Int? // phase B
|
||||
cancelledAt DateTime?
|
||||
order Int @default(0)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
usages TicketUsage[]
|
||||
ticketSessions TicketSession[]
|
||||
|
||||
@@index([eventId, startsAt])
|
||||
}
|
||||
|
||||
model TicketUsage {
|
||||
// ...existing fields
|
||||
sessionId String? // null on single-date events
|
||||
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
// Phase B only: which sessions a "pick sessions" ticket covers.
|
||||
model TicketSession {
|
||||
ticketId String
|
||||
sessionId String
|
||||
@@id([ticketId, sessionId])
|
||||
}
|
||||
```
|
||||
|
||||
`Ticket.isUsed` keeps its meaning for single-date events. For session events
|
||||
it's not used for check-in decisions; "checked in" is per session
|
||||
(`TicketUsage.sessionId`).
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Session CRUD + pattern generator in `backend/src/utils/sessions.js` with
|
||||
Jest tests (DST-free but still test month boundaries, skipped dates, "until"
|
||||
vs "count").
|
||||
- Scan endpoint takes an optional `sessionId`; defaults to the current session.
|
||||
- Help content, audit log entries (session created/moved/cancelled),
|
||||
CHANGELOG, FEATURES.md, SETTINGS.md.
|
||||
|
||||
---
|
||||
|
||||
## 2. Payment providers: PayFast, SnapScan, Zapper
|
||||
|
||||
Today Yoco is the only online provider. Add **PayFast**, **SnapScan** and
|
||||
**Zapper**, and let the admin **enable one or more** in Site settings. At
|
||||
checkout the user picks from the enabled ones.
|
||||
|
||||
**Who:** Admins configure providers. Users choose at payment. Supervisors
|
||||
reconcile.
|
||||
|
||||
### Site settings → Payments
|
||||
|
||||
| Setting | Options |
|
||||
|---------|---------|
|
||||
| Providers | Yoco · PayFast · SnapScan · Zapper — each with an **Enabled** toggle |
|
||||
| Credentials | Per provider (merchant id/key, API key, passphrase, webhook secret…), stored encrypted (`utils/encryption.js`, same as WhatsApp) |
|
||||
| Mode | Live / Sandbox per provider (where supported) |
|
||||
| Display order / default | Which one is shown first. Default order: Yoco · PayFast · SnapScan · Zapper |
|
||||
| Minimum amount | **R15 for all providers** (one site-wide minimum for online payments) |
|
||||
|
||||
**Fees are not handled automatically.** Payments are recorded at the full
|
||||
amount paid; provider fees are not deducted, estimated or added to the payer's
|
||||
total. Fees are dealt with by people during **cashup**, as today. There is no
|
||||
"pass fee to payer" option.
|
||||
|
||||
"Test connection" button per provider, like the WhatsApp setup.
|
||||
|
||||
### How it works
|
||||
|
||||
- A single **PaymentProvider interface** in the backend:
|
||||
`createCheckout`, `handleWebhook` (verify signature → mark paid),
|
||||
`getTransaction`, `refund` (if the provider supports it via API),
|
||||
`listTransactions` (for reconciliation).
|
||||
- Yoco is moved behind the same interface first (no behaviour change), then
|
||||
PayFast, SnapScan and Zapper are added one release each.
|
||||
- SnapScan and Zapper are QR/app-based: on desktop show the QR, on mobile
|
||||
deep-link into the app; the page polls/waits for the webhook and moves on
|
||||
automatically when payment lands.
|
||||
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to
|
||||
`ProviderTransaction` (`provider` column) so the existing reconciliation page
|
||||
works for all providers.
|
||||
- Refunds: if the provider has no refund API, the supervisor refunds in the
|
||||
provider's portal and records it here (existing manual refund flow).
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Provider disabled while a checkout is in progress → webhook still accepted
|
||||
and processed.
|
||||
- Duplicate / late webhooks → idempotent on the provider's transaction id.
|
||||
- Webhook never arrives → "Check payment status" button and a periodic
|
||||
reconcile job.
|
||||
- Amount mismatch (user edits amount in app) → payment held as "needs review".
|
||||
- Only enabled providers appear in kiosk / self-service.
|
||||
- Amount below R15 → online payment options hidden, with a message to pay at
|
||||
the door / contact the organiser.
|
||||
- **Before building each one:** get the documentation links from Joshua and
|
||||
confirm current API docs for webhooks, sandbox, refunds and QR deep-link
|
||||
support.
|
||||
|
||||
---
|
||||
|
||||
## 3. POPIA toolkit
|
||||
|
||||
Tools that make the platform **POPIA-ready** out of the box. This is the main
|
||||
selling point for other churches and organisations: "your attendee data is
|
||||
handled properly, and you can prove it."
|
||||
|
||||
> This is tooling to support compliance, not legal advice. Wording and
|
||||
> retention periods should be checked by whoever is the customer's
|
||||
> Information Officer.
|
||||
|
||||
**Who:** Admins (and the appointed Information Officer) manage it. Users get
|
||||
self-service privacy tools.
|
||||
|
||||
### For users (data subjects)
|
||||
|
||||
- **Privacy centre** in My account:
|
||||
- **Download my data** — JSON + readable PDF of profile, registrations,
|
||||
payments, tickets, form answers, consents, messages sent.
|
||||
- **Correct my data** — edit profile; request correction of locked records.
|
||||
- **Delete my account** — creates a deletion request (see below).
|
||||
- **Communication preferences** — email / WhatsApp / marketing opt-in with
|
||||
timestamps (extends the existing notification preferences).
|
||||
- **Unsubscribe link** in every bulk email and an opt-out keyword for bulk
|
||||
WhatsApp. Opted-out users are skipped automatically in broadcasts.
|
||||
- **Privacy notice acceptance** — versioned; users accept the current version
|
||||
at registration, and are asked again when it changes.
|
||||
|
||||
### For admins
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| Information Officer | Name, contact details, shown on privacy notice and contact page |
|
||||
| Privacy notice editor | Versioned text, published version history |
|
||||
| Consent log | Who consented to what, when, which notice version, how (web, kiosk, WhatsApp) |
|
||||
| Data requests inbox | Access, correction and deletion requests with status, due dates and audit trail |
|
||||
| Deletion workflow | Admin approves → personal info is **anonymised**; financial records kept (amounts, dates) but unlinked from the person, as tax law requires them to be kept |
|
||||
| Retention policies | Per data category (inactive accounts, form answers, medical details, analytics, audit log, backups): keep for N months, then anonymise/delete via nightly job. Sensible defaults shipped |
|
||||
| Operator register | List of third parties that process data (Yoco/PayFast/…, WhatsApp provider, email provider, Sentry, hosting) with purpose; feeds the privacy notice |
|
||||
| Access log | Who exported or viewed personal data in bulk (exports, attendee lists, medical details) |
|
||||
| Breach register | Record a security compromise, affected users, actions taken; templates for notifying users and the Information Regulator |
|
||||
| Children's data | Already covered by parental consent; flagged as special/children's info in reports |
|
||||
| POPIA readiness page | Checklist with score: officer set, notice published, retention set, operators listed, unsubscribe working… |
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Deletion of a user with an open registration / upcoming event → request
|
||||
waits until after the event, or the admin cancels and refunds first.
|
||||
- Anonymised users still count in historic reports ("Deleted user").
|
||||
- Backups contain deleted data until they age out — stated in the notice;
|
||||
backup retention is one of the retention policies.
|
||||
- Impersonation and exports must be logged in the access log.
|
||||
|
||||
---
|
||||
|
||||
## 4. Kids check-in with security labels
|
||||
|
||||
A secure check-in/check-out flow for children's ministry and youth events.
|
||||
**Builds on sessions (§1), attendees, parental consent and allocation groups.**
|
||||
|
||||
**Who:** Parents check in children (self-service kiosk or staff desk).
|
||||
Kids-ministry staff run rooms. Supervisors set up rooms.
|
||||
|
||||
### How it works
|
||||
|
||||
- Parent finds their family (phone number / QR from My account) and ticks the
|
||||
children being dropped off. New children can be added on the spot.
|
||||
- The system prints **two labels**:
|
||||
- **Child label:** name, room, allergy/medical icons, short pickup code.
|
||||
- **Parent label:** the same pickup code.
|
||||
- **Check-out** requires scanning or typing the matching code. A mismatch
|
||||
alerts the room leader.
|
||||
- Rooms come from allocation groups (age/grade rules), with **ratios** (e.g.
|
||||
1 leader per 5 toddlers) and live room counts.
|
||||
- **Page a parent:** room leader taps "Call parent" → WhatsApp/SMS to the
|
||||
parent ("Please come to the Toddlers room").
|
||||
- Attendance per child per session feeds the attendance register.
|
||||
|
||||
### Plumbing / edge cases
|
||||
|
||||
- Label printing via browser print with label-size CSS (Dymo / Brother label
|
||||
printers, 62 mm and 89×36 mm presets); PDF fallback.
|
||||
- Allergies and medical info come from the consent/medical fields; respect
|
||||
the medical-info deletion rules.
|
||||
- Custody notes ("Do not release to …") visible to room leaders only.
|
||||
- Works on the existing offline-scanning foundations for flaky Wi-Fi.
|
||||
|
||||
---
|
||||
|
||||
## 5. Volunteer / serving rosters
|
||||
|
||||
Plan who serves where, for an event or each session.
|
||||
|
||||
- **Serving roles** per event/session: Welcome, Parking, Kids, Sound, Kitchen…
|
||||
with the number of people needed.
|
||||
- Volunteers **sign up** for open slots, or supervisors **assign** people.
|
||||
- Reminders via email/WhatsApp before their slot; "can't make it" → swap
|
||||
request to others in the role.
|
||||
- **Volunteer check-in** on the day (separate from attendee check-in).
|
||||
- Volunteers can get a free/discounted ticket automatically (uses coupons/comp).
|
||||
- Reports: roster sheet per session, fill rate, hours served per person.
|
||||
|
||||
---
|
||||
|
||||
## 6. Event series and cloning
|
||||
|
||||
- **Clone event:** copy an event with its options, variants, early-bird tiers,
|
||||
forms, consent text, feedback form, cost template, allocation groups and
|
||||
sessions (dates shifted to the new start). No registrations.
|
||||
- **Series:** group separate events (Camp 2025, Camp 2026; monthly Men's
|
||||
Breakfast) on one series page, with "Next in this series" and a series
|
||||
follow/notify option.
|
||||
- **Year-on-year reports:** registrations, income and attendance compared
|
||||
across events in a series.
|
||||
- **Season pass:** a bundle across events in a series (extends event bundles).
|
||||
|
||||
---
|
||||
|
||||
## 7. Apple and Google Wallet tickets
|
||||
|
||||
- "Add to Apple Wallet" / "Add to Google Wallet" buttons next to the PDF
|
||||
ticket, in email and in My Events.
|
||||
- Pass shows event, date (next session for multi-day), location, holder name,
|
||||
QR code.
|
||||
- Passes **update** when the event/session changes (time, venue, cancelled).
|
||||
- Requires an Apple Developer account (pass signing certificate) and a Google
|
||||
Wallet issuer account; both configured in Site settings and **optional** —
|
||||
hidden when not configured.
|
||||
|
||||
---
|
||||
|
||||
## 8. Outbound webhooks
|
||||
|
||||
Let other systems react to what happens in Hope Events.
|
||||
|
||||
- Admin adds endpoints and picks events: `registration.created`,
|
||||
`registration.paid`, `registration.cancelled`, `payment.refunded`,
|
||||
`ticket.checked_in`, `user.created`, `event.published`…
|
||||
- Payloads signed with HMAC (secret per endpoint), retries with back-off,
|
||||
delivery log with "Resend".
|
||||
- Enables integrations (ChurchSuite, Planning Center, Google Sheets via
|
||||
Zapier/Make) without building each one. Native integrations can follow later.
|
||||
|
||||
---
|
||||
|
||||
## 9. Admin dashboard, command palette, bulk actions
|
||||
|
||||
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
|
||||
far, money in (today/week), pending items (unreconciled payments, consent
|
||||
outstanding, waitlist offers, data requests), alerts.
|
||||
- **Command palette (Ctrl+K):** jump to any event, user, registration,
|
||||
ticket or setting; quick actions ("New event", "Find payment R450").
|
||||
- **Bulk actions** on registration/attendee lists: message, export, mark
|
||||
paid, move option, cancel, allocate.
|
||||
- **Saved filters** per user on list pages.
|
||||
|
||||
---
|
||||
|
||||
## 10. Languages (Afrikaans first)
|
||||
|
||||
- i18n framework on the frontend (e.g. `next-intl`); English + Afrikaans.
|
||||
- User language preference; emails, WhatsApp messages and PDFs sent in the
|
||||
user's language, with per-language templates.
|
||||
- Event content stays as typed by the supervisor (optional translated title /
|
||||
description fields later).
|
||||
|
||||
---
|
||||
|
||||
## 11. Accessibility pass (WCAG 2.2 AA)
|
||||
|
||||
- Audit public pages, registration, payment, My Events and the kiosk.
|
||||
- Keyboard navigation, focus states, labels, colour contrast (light and
|
||||
dark), screen-reader names for icon buttons, error messages linked to
|
||||
fields.
|
||||
- Automated `axe` checks in the Playwright suite so it doesn't regress.
|
||||
|
||||
---
|
||||
|
||||
## 12. Multi-campus (one install per church)
|
||||
|
||||
**Decision: one install per church.** Each customer gets their own install
|
||||
and database (the `/setup` wizard already makes this possible). This gives
|
||||
full data separation — which also helps the POPIA story — and lets each
|
||||
church run on a different server. **True multi-tenant is out of scope.**
|
||||
|
||||
**Multi-campus** is in scope: one church with several campuses inside one
|
||||
install.
|
||||
|
||||
### Setup
|
||||
|
||||
- Site settings → **Campuses**: off by default. When on, admins add campuses
|
||||
(name, address, contact, optional logo/colour accent, default location for
|
||||
events).
|
||||
- Single-campus churches never see campus fields anywhere.
|
||||
|
||||
### How it works
|
||||
|
||||
- **Events** belong to one campus, or to **All campuses** (church-wide
|
||||
conferences, camps).
|
||||
- **Public site:** campus filter on the events list, remembered per visitor;
|
||||
campus shown on event cards and tickets. Optional campus landing pages
|
||||
(`/campus/<slug>`).
|
||||
- **Users** can set a home campus (used as the default filter and for
|
||||
broadcasts). Accounts stay shared across campuses — one login for the
|
||||
church.
|
||||
- **Campus-scoped roles:** a supervisor or staff member can be limited to
|
||||
one or more campuses (sees and manages only those events, registrations,
|
||||
scanning, cashups). Admins and unscoped supervisors see everything.
|
||||
- **Reports, cashup, dashboard, funnel stats:** filter by campus plus a
|
||||
church-wide total.
|
||||
- **Broadcasts:** target "everyone at campus X".
|
||||
- **Kids check-in rooms** and **volunteer roles** are per campus.
|
||||
- **Notification recipients** per campus (e.g. the campus pastor gets that
|
||||
campus's daily summary).
|
||||
|
||||
### Edge cases
|
||||
|
||||
- Turning campuses off with campus data present → hidden, not deleted.
|
||||
- Deleting a campus with events → blocked; archive instead.
|
||||
- Moving an event to another campus → scoped supervisors of the old campus
|
||||
lose access; logged in the audit log.
|
||||
|
||||
### Data model (sketch)
|
||||
|
||||
```prisma
|
||||
model Campus {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
address String?
|
||||
isActive Boolean @default(true)
|
||||
order Int @default(0)
|
||||
events Event[]
|
||||
}
|
||||
// Event.campusId String? (null = all campuses)
|
||||
// User.homeCampusId String?
|
||||
// UserCampusScope { userId, campusId } — empty = unscoped
|
||||
```
|
||||
|
||||
### Running many installs
|
||||
|
||||
Because every church is a separate install, a few tools make that manageable:
|
||||
|
||||
- **Install script:** fresh server → Node, Postgres, PM2, reverse proxy,
|
||||
`.env`, migrations, then `/setup` in the browser.
|
||||
- **Update script:** pull a tagged version, back up the database, run
|
||||
migrations, build, restart; roll back on failure.
|
||||
- **Version and health:** current version shown in admin and on a `/health`
|
||||
endpoint (version, DB, jobs, last backup), reported to the
|
||||
[Hub](#13-central-management-platform-hope-events-hub).
|
||||
|
||||
---
|
||||
|
||||
## 13. Central management platform (Hope Events Hub)
|
||||
|
||||
A separate app, run by Joshua, to see and manage **every Hope Events
|
||||
install** from one place: who the customers are, which version each runs,
|
||||
whether they're healthy, basic usage stats, and their **licence** status.
|
||||
|
||||
**Who:** Joshua (and any future support staff). Customers never log in to it.
|
||||
|
||||
### Separate app, separate repo, separate server
|
||||
|
||||
- **Its own git repository:** `joshua/hope-events-hub` on the same Gitea
|
||||
server (c-git.crosscode.co.za), with its own semver tags, CHANGELOG and
|
||||
deploys. It is never shipped to churches, so none of its code (customers,
|
||||
licence signing key, other churches' data) ends up on a customer's server.
|
||||
- Same stack (Next.js + Express + Prisma + Postgres) so it's familiar.
|
||||
- **What lives where:**
|
||||
- Hub repo: dashboard, customers, licences, licence **signing** (private
|
||||
key — only ever on the Hub server), `/api/installs/register` and
|
||||
`/api/installs/heartbeat` endpoints, alerts.
|
||||
- Hope Events repo: the Hub **client** — licence step in `/setup`,
|
||||
"Connect to Hub" in Site settings, heartbeat job, licence verification
|
||||
with the Hub's **public** key, read-only mode, banners.
|
||||
- **The contract between them** (register / heartbeat payloads, licence token
|
||||
format) is versioned with a `protocolVersion` field and documented in both
|
||||
repos (`docs/hub-protocol.md`). The Hub accepts the current and previous
|
||||
protocol version, so churches on older versions keep reporting while they
|
||||
wait to update.
|
||||
- Runs on its own server/domain (e.g. `hub.<your-domain>`). If the Hub is
|
||||
down, **no church install is affected**.
|
||||
- Hope Family Church's own site is the first registered install.
|
||||
|
||||
### Registration (setup → Hub)
|
||||
|
||||
1. The `/setup` wizard gets a **Licence** step: the customer enters the
|
||||
**licence key** Joshua issued (created in the Hub first).
|
||||
2. When setup completes, the install sends `POST /api/installs/register`
|
||||
with: licence key, site name, URL, admin contact, version, server info
|
||||
(Node/Postgres version, OS), timezone, enabled features.
|
||||
3. The Hub checks the key, links the install to the customer, and returns an
|
||||
**install id + install secret** (stored encrypted in AppSettings) and a
|
||||
**signed licence token**.
|
||||
4. Existing installs (already past setup) get a **"Connect to Hub"** button
|
||||
in Site settings → Licence.
|
||||
|
||||
All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
|
||||
### Heartbeat (install → Hub)
|
||||
|
||||
- Every 15 minutes (job queue) the install POSTs a heartbeat:
|
||||
- **Health:** version, uptime, DB OK, job queue OK, last successful
|
||||
backup, disk free, last error count (from Sentry or local logs), pending
|
||||
migrations.
|
||||
- **Basic stats (aggregate counts only):** users, active/upcoming events,
|
||||
registrations and tickets (30 days / all time), check-ins, WhatsApp/email
|
||||
sent, enabled payment providers, campuses on/off.
|
||||
- **Optional (off by default, customer chooses):** payment totals.
|
||||
- **Install → Hub only.** The install pushes; the Hub never needs to reach
|
||||
into the install, so it works behind firewalls and the Hub has no access
|
||||
to church data.
|
||||
- **No personal information is ever sent** (no names, emails, phones,
|
||||
attendee data). Listed in the install's POPIA operator register and
|
||||
privacy notice as "aggregate usage statistics to the software provider".
|
||||
|
||||
### Hub dashboard
|
||||
|
||||
- **Installs list:** customer, URL, version (flag if behind the latest tag),
|
||||
licence status, last heartbeat (red if > 1 h ago), health status.
|
||||
- **Install detail:** health history, stats charts, version history,
|
||||
licence history, notes, contacts.
|
||||
- **Alerts** (email/WhatsApp to Joshua): install offline > 1 h, backup
|
||||
failed or older than 48 h, disk low, errors spiking, licence expiring.
|
||||
- **Releases:** latest version and changelog; installs see "Update
|
||||
available" in their admin.
|
||||
- **Customers:** contact details, plan, licence(s), billing notes.
|
||||
|
||||
### Licences
|
||||
|
||||
| State | What the install does |
|
||||
|-------|----------------------|
|
||||
| **Active** | Everything works |
|
||||
| **Grace** (expired or Hub unreachable) | Everything works; admins see a banner "Licence needs renewing — X days left". Default 14 days |
|
||||
| **Suspended** | **Read-only:** no new events, no new registrations or payments. Existing tickets **still scan**, and admins can still view and **export all data** |
|
||||
| **Revoked** | Same as suspended, with a permanent banner |
|
||||
|
||||
- Joshua turns a licence on/off, sets expiry dates and plan in the Hub; the
|
||||
install picks up the change on its next heartbeat.
|
||||
- The licence is a **signed token** (Ed25519: the Hub signs with its private
|
||||
key, installs only hold the public key). Installs verify it **offline**, so
|
||||
a Hub outage never switches a church off, and a token can't be faked by
|
||||
editing the database.
|
||||
- **Never a hard kill:** a church must always be able to scan tickets for
|
||||
events people already paid for, and get its own data out (POPIA access
|
||||
rights). Suspension blocks *new* activity only.
|
||||
- Optional plan limits later (e.g. number of campuses, WhatsApp) carried in
|
||||
the token.
|
||||
|
||||
### Things to be aware of
|
||||
|
||||
- Code runs on the customer's server, so a determined developer could patch
|
||||
out the licence check. Real protection is the **contract** plus updates and
|
||||
support only being available to valid licences — the licence check keeps
|
||||
honest customers honest.
|
||||
- The Hub should not offer remote commands (run updates, impersonate,
|
||||
database access) in v1: it's the most valuable target an attacker could
|
||||
hit, so keeping it read-only toward installs keeps every church safe if it
|
||||
is ever compromised. Remote "trigger update" can be revisited later.
|
||||
- The Hub itself needs 2FA, backups and the same audit log approach.
|
||||
|
||||
### Data model (Hub, sketch)
|
||||
|
||||
```prisma
|
||||
model Customer { id, name, contactName, email, phone, notes, licences, installs }
|
||||
model Licence { id, customerId, key, plan, status, expiresAt, graceDays, issuedAt }
|
||||
model Install { id, customerId, licenceId, name, url, secretHash, version,
|
||||
lastHeartbeatAt, health Json, registeredAt }
|
||||
model Heartbeat { id, installId, receivedAt, version, health Json, stats Json }
|
||||
// rolled up to daily after 30 days
|
||||
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Foundations (platform work)
|
||||
|
||||
Not visible to users, but this is what makes 2.0 a safe product to sell and
|
||||
keep adding to.
|
||||
|
||||
| Item | What |
|
||||
|------|------|
|
||||
| CI (Gitea Actions) | Lint, typecheck, Jest, build on every push; deploy on tag |
|
||||
| Playwright E2E | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk |
|
||||
| Job queue | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin |
|
||||
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite |
|
||||
| TypeScript backend | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend |
|
||||
| API v2 | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition |
|
||||
| Dependencies | Express 5, native `fetch` instead of `node-fetch`, password hashing upgrade with rehash-on-login |
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Version | Features | Why |
|
||||
|-------|---------|----------|-----|
|
||||
| 0 | 1.32.0 ✅ | **Early-bird ribbon** on event cards (shipped 2026-10-22) | Quick win |
|
||||
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
|
||||
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 – 1.39.0 | Payment provider interface (Yoco moved behind it) → PayFast → SnapScan → Zapper, one release each | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.40.0 – 1.41.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.42.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.43.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.44.0 – 1.45.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.46.0 – 1.48.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
|
||||
and SETTINGS.md — same as the previous round. Branch → merge → tag → push per
|
||||
feature.
|
||||
|
||||
---
|
||||
|
||||
## Decisions (2026-10-22)
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|----------|----------|
|
||||
| 1 | Pick sessions in the first release? | No — whole-course first (1.33.0), pick sessions as a separate release (1.35.0) |
|
||||
| 2 | Late joining and late price | Set **per event**: late joining on/off (until session N), and the late price (full / pro-rata / fixed) |
|
||||
| 3 | Check-in window | Per event, **whole day** by default (any time on the session's date); "session time only" as the alternative |
|
||||
| 4 | Certificates | Yes — four templates (Classic, Modern, Church, Kids); the event creator chooses whether to issue certificates and which template |
|
||||
| 5 | Session reminders | Off by default, configurable per event |
|
||||
| 6 | Selling model | **One install per church** — data separation, any server. No multi-tenant. **Multi-campus** inside an install is in scope |
|
||||
| — | Pro-rata rounding | Nearest R10 |
|
||||
| — | Multi-campus | In scope (§12), inside one install |
|
||||
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
|
||||
| 7 | Payment provider order | Yoco · PayFast · SnapScan · Zapper. Joshua can supply documentation links |
|
||||
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
|
||||
| — | Minimum online payment | R15 for all providers |
|
||||
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
|
||||
| — | Early-bird ribbon | Added before everything else; shipped as 1.32.0, all later versions moved up by one |
|
||||
|
||||
## Still open
|
||||
|
||||
Hub (needed before phase 6, not before 1.33.0):
|
||||
|
||||
1. Hub domain and server, and where licence keys come from (created by hand
|
||||
in the Hub per customer?).
|
||||
2. Grace period length (proposed 14 days) and suspended = read-only (tickets
|
||||
still scan, data export still works) — agreed?
|
||||
3. Should payment totals be in the heartbeat at all (opt-in per customer), or
|
||||
counts only?
|
||||
4. Licence plans/tiers (e.g. limits on campuses or WhatsApp), or one plan for
|
||||
now?
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type Role = "admin" | "supervisor" | "staff" | "user";
|
||||
export type Seed = {
|
||||
users: Record<Role, { id: string; name: string; email: string; token: string }>;
|
||||
events: { free: string; paid: string; course: string; dropIn: string; conference: string };
|
||||
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
|
||||
};
|
||||
|
||||
export const API = "http://localhost:5100";
|
||||
|
||||
// Written by e2e/support/prepare-db.js when the e2e backend starts, so read lazily.
|
||||
let cached: Seed | null = null;
|
||||
export function seed(): Seed {
|
||||
if (!cached) cached = JSON.parse(fs.readFileSync(path.join(__dirname, ".data", "seed.json"), "utf8")) as Seed;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Signs the page in as a seeded user (the token goes where the app keeps it). */
|
||||
export async function loginAs(page: Page, role: Role) {
|
||||
await page.addInitScript((token) => {
|
||||
localStorage.setItem("hope_events_token", token);
|
||||
}, seed().users[role].token);
|
||||
}
|
||||
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
// Skip the cookie notice so it never covers what the tests click.
|
||||
await page.addInitScript(() => localStorage.setItem("hope_events_cookie_consent", "declined"));
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
/** Calls the e2e API as a seeded user. */
|
||||
export async function api<T = unknown>(page: Page, role: Role, method: string, url: string, body?: unknown): Promise<T> {
|
||||
const res = await page.request.fetch(`${API}${url}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${seed().users[role].token}`, "Content-Type": "application/json" },
|
||||
data: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok()) throw new Error(`${method} ${url} → ${res.status()}: ${text}`);
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
export { expect };
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
type Registration = { id: string; eventId: string; user?: { name: string }; registrationOptions: { sessionId?: string | null; sessionIds?: string[]; priceSnapshot: number; quantity: number }[] };
|
||||
type WaitlistEntry = { eventId: string; sessionId: string | null; quantity: number };
|
||||
|
||||
test.describe("pick sessions", () => {
|
||||
test("book two classes of a drop-in event; a full class can't be ticked", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.dropIn}`);
|
||||
await expect(page.getByText("R50.00 per session").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await expect(picker.getByText("Full")).toBeVisible();
|
||||
await expect(picker.getByRole("checkbox").first()).toBeDisabled();
|
||||
await picker.getByRole("checkbox").nth(1).check();
|
||||
await picker.getByRole("checkbox").nth(2).check();
|
||||
await expect(page.getByText("Total: R100.00")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await page.waitForURL(/\/registration\/success/);
|
||||
|
||||
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
|
||||
const reg = regs.find((r) => r.eventId === seed().events.dropIn);
|
||||
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("an all-sessions pass: tick the days, pay one price", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.conference}`);
|
||||
await expect(page.getByText("R200.00", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("per session")).toHaveCount(0);
|
||||
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await picker.getByRole("checkbox").nth(0).check();
|
||||
await picker.getByRole("checkbox").nth(2).check();
|
||||
await expect(page.getByText("Total: R200.00")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await page.waitForURL(/\/registration\/success/);
|
||||
|
||||
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
|
||||
const line = regs.find((r) => r.eventId === seed().events.conference)?.registrationOptions[0];
|
||||
expect(line?.sessionId ?? null).toBeNull();
|
||||
expect(line?.sessionIds).toHaveLength(2);
|
||||
expect(line?.priceSnapshot).toBe(200);
|
||||
expect(line?.quantity).toBe(1);
|
||||
});
|
||||
|
||||
test("join the waitlist for a full class", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto(`/register/${seed().events.dropIn}`);
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await picker.getByRole("button", { name: "Join waitlist" }).click();
|
||||
await expect(page.getByText(/Drop-in — Class 1/)).toBeVisible();
|
||||
await page.locator("form").filter({ hasText: "Join the waitlist" }).getByRole("button", { name: "Join waitlist" }).click();
|
||||
await expect(page.getByText(/You're on the waitlist/)).toBeVisible();
|
||||
|
||||
const mine = await api<WaitlistEntry[]>(page, "admin", "GET", "/api/waitlist/mine");
|
||||
const entry = mine.find((e) => e.eventId === seed().events.dropIn);
|
||||
expect(entry?.sessionId).toBeTruthy();
|
||||
expect(entry?.quantity).toBe(1);
|
||||
});
|
||||
|
||||
test("at the door: book a class for a walk-in", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/at-the-door");
|
||||
const value = await page.locator("option", { hasText: "E2E Drop-in Class" }).getAttribute("value");
|
||||
// The page picks a default event once its list loads; choose ours after that.
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.locator("select").first().selectOption(value!);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator("select").first()).toHaveValue(value!);
|
||||
|
||||
await page.getByPlaceholder("Name, email or phone…").fill("Door Walk In");
|
||||
await page.getByText('New attendee "Door Walk In"…').click();
|
||||
await page.getByPlaceholder("Email address").fill("door-walkin@e2e.test");
|
||||
await page.getByRole("button", { name: "Next — Choose Items" }).click();
|
||||
|
||||
await expect(page.getByText("R 50.00 per session")).toBeVisible();
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await picker.getByRole("checkbox").nth(1).check();
|
||||
await expect(page.getByText("Total: R 50.00")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Confirm Registration" }).click();
|
||||
await expect(page.getByText("Select Items")).toBeHidden();
|
||||
|
||||
const regs = await api<Registration[]>(page, "supervisor", "GET", `/api/registrations/event/${seed().events.dropIn}`);
|
||||
const reg = regs.find((r) => r.user?.name === "Door Walk In");
|
||||
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test, expect, seed } from "./fixtures";
|
||||
|
||||
test.describe("public pages", () => {
|
||||
test("events list shows events, with the multi-day summary", async ({ page }) => {
|
||||
await page.goto("/events");
|
||||
await expect(page.getByRole("heading", { name: "All Events" })).toBeVisible();
|
||||
await expect(page.getByText("E2E Free Evening")).toBeVisible();
|
||||
const course = page.locator("div.border.rounded-xl", { hasText: "E2E Alpha Course" });
|
||||
await expect(course.getByText(/5 sessions · /)).toBeVisible();
|
||||
});
|
||||
|
||||
test("a multi-day event page shows the schedule and the late-joiner price", async ({ page }) => {
|
||||
await page.goto(`/events/${seed().events.course}`);
|
||||
await expect(page.getByRole("heading", { name: "E2E Alpha Course" })).toBeVisible();
|
||||
const schedule = page.locator("div.rounded-xl", { has: page.getByRole("heading", { name: "Schedule" }) });
|
||||
await expect(schedule.getByText("Week 1")).toBeVisible();
|
||||
await expect(schedule.getByText("Week 5")).toBeVisible();
|
||||
await expect(schedule.getByText("Next")).toBeVisible();
|
||||
// Week 2 is in progress, so 3 of 5 are still to come: R300 × 3/5 = R180.
|
||||
await expect(page.getByText(/3 of 5 sessions are still to come/).filter({ visible: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("R180.00").filter({ visible: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("an unknown certificate code is reported as not found", async ({ page }) => {
|
||||
await page.goto("/certificates/verify/ABCDE-12345");
|
||||
await expect(page.getByRole("heading", { name: "Certificate not found" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
type Registration = { id: string; status: string; eventId: string };
|
||||
type Ticket = { id: string; qrCode: string; eventId: string };
|
||||
|
||||
type CashByUser = { rows: { userId: string | null; cash: { total: number; count: number } }[] };
|
||||
|
||||
test.describe("registration → payment → ticket → check-in → cashup", () => {
|
||||
test("a free event: register and get a ticket straight away", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.free}`);
|
||||
await expect(page.getByText("E2E Free Evening").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await page.waitForURL(/\/registration\/success/);
|
||||
|
||||
const tickets = await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets");
|
||||
expect(tickets.some((t) => t.eventId === seed().events.free)).toBe(true);
|
||||
});
|
||||
|
||||
test("a paid event: pending until paid, then a ticket that checks in once", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.paid}`);
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await expect(page.getByText("Total: R150.00")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await page.waitForURL(/\/registration\/success/);
|
||||
|
||||
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
|
||||
const reg = regs.find((r) => r.eventId === seed().events.paid);
|
||||
expect(reg?.status).not.toBe("paid");
|
||||
expect((await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets")).some((t) => t.eventId === seed().events.paid)).toBe(false);
|
||||
|
||||
// A supervisor records the cash payment at the desk.
|
||||
await api(page, "supervisor", "POST", "/api/payments", { amount: 150, method: "cash", registrationId: reg!.id, eventId: seed().events.paid, userId: seed().users.user.id });
|
||||
const ticket = (await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets")).find((t) => t.eventId === seed().events.paid);
|
||||
expect(ticket).toBeTruthy();
|
||||
|
||||
// My Events shows it.
|
||||
await page.goto("/dashboard/user");
|
||||
await expect(page.getByText("E2E Paid Conference").first()).toBeVisible();
|
||||
|
||||
// Staff check it in; a second scan is refused.
|
||||
await api(page, "staff", "POST", `/api/tickets/scan/${ticket!.qrCode}`, { qty: 1 });
|
||||
await expect(api(page, "staff", "POST", `/api/tickets/scan/${ticket!.qrCode}`, { qty: 1 })).rejects.toThrow(/403.*fully used/);
|
||||
|
||||
// Cashup: the R150 cash is on the supervisor who took it.
|
||||
const cash = await api<CashByUser>(page, "supervisor", "GET", `/api/cashups/event/${seed().events.paid}/cash-by-user`);
|
||||
const mine = cash.rows.find((r) => r.userId === seed().users.supervisor.id);
|
||||
expect(mine?.cash.total).toBe(150);
|
||||
expect(mine?.cash.count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
test.describe("multi-day events", () => {
|
||||
test("check-in is once per session, and the register and certificates follow", async ({ page }) => {
|
||||
const { course, events } = seed();
|
||||
|
||||
// Scanning today: Week 2.
|
||||
const scan = await api<{ session: { label: string } }>(page, "staff", "POST", `/api/tickets/scan/${course.qrCode}`, { qty: 1 });
|
||||
expect(scan.session.label).toBe("Week 2");
|
||||
await expect(api(page, "staff", "POST", `/api/tickets/scan/${course.qrCode}`, { qty: 1 })).rejects.toThrow(/Already checked in for Week 2/);
|
||||
|
||||
// The register shows weeks 1 and 2 ticked.
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto(`/dashboard/supervisor/attendance?eventId=${events.course}`);
|
||||
const row = page.locator("tr", { hasText: "Thandi Test" });
|
||||
await expect(row.getByText("2/5")).toBeVisible();
|
||||
|
||||
// Tick week 3 by hand (e.g. let in without a scan), then untick it.
|
||||
const week3 = row.locator("td").nth(3).getByRole("button");
|
||||
await week3.click();
|
||||
await expect(row.getByText("3/5")).toBeVisible();
|
||||
await week3.click();
|
||||
await expect(row.getByText("2/5")).toBeVisible();
|
||||
|
||||
// Two sessions meets the rule (at least 2), so a certificate can be issued now.
|
||||
await page.getByRole("button", { name: "Issue now" }).click();
|
||||
await expect(page.getByText("1 certificate issued.")).toBeVisible();
|
||||
const code = (await row.locator("span.font-mono").innerText()).trim();
|
||||
expect(code).toMatch(/^[A-Z0-9]{5}-[A-Z0-9]{5}$/);
|
||||
|
||||
// Anyone can check it.
|
||||
await page.goto(`/certificates/verify/${code}`);
|
||||
await expect(page.getByRole("heading", { name: "Valid certificate" })).toBeVisible();
|
||||
await expect(page.getByText("Thandi Test")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the attendee sees their certificate on My Events", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto("/dashboard/user");
|
||||
await expect(page.getByRole("heading", { name: "My Certificates" })).toBeVisible();
|
||||
await expect(page.getByText("E2E Alpha Course").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("the scanner offers today's session for a multi-day event", async ({ page }) => {
|
||||
await loginAs(page, "staff");
|
||||
await page.goto("/dashboard/staff/ticket-scanning");
|
||||
await page.locator("select").first().selectOption({ label: "E2E Alpha Course" });
|
||||
await expect(page.locator("select", { has: page.locator("option", { hasText: "Today's session" }) })).toBeVisible();
|
||||
await expect(page.getByText("Today's session (Week 2)")).toBeAttached();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
type EventRow = { id: string; title: string; scheduleType?: string; sessions?: { cancelled?: boolean }[] };
|
||||
|
||||
test.describe("supervisor tools", () => {
|
||||
test("create a weekly multi-session event in the wizard", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/events");
|
||||
await page.getByRole("button", { name: "+ Add Event" }).click();
|
||||
await expect(page.getByRole("heading", { name: "New Event" })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Event title").fill("E2E Wizard Course");
|
||||
await page.getByRole("button", { name: "Multiple sessions" }).click();
|
||||
// The repeat pattern defaults to weekly, 18:30–20:30, 5 sessions from today.
|
||||
await page.getByRole("button", { name: "Create sessions" }).click();
|
||||
await expect(page.getByText(/^5 sessions — edit any date/)).toBeVisible();
|
||||
await page.locator('input[type="number"][placeholder="0"]').first().fill("250");
|
||||
|
||||
// Through the remaining steps to Create Event.
|
||||
const create = page.getByRole("button", { name: "Create Event" });
|
||||
for (let i = 0; i < 15 && !(await create.isVisible()); i++) {
|
||||
await page.getByRole("button", { name: "Next →" }).click();
|
||||
}
|
||||
await create.click();
|
||||
await expect(page.getByRole("heading", { name: "New Event" })).toBeHidden();
|
||||
|
||||
const row = page.locator("li", { hasText: "E2E Wizard Course" });
|
||||
await expect(row.getByText(/5 sessions · /)).toBeVisible();
|
||||
const all = await api<EventRow[]>(page, "supervisor", "GET", "/api/events/all");
|
||||
const created = all.find((e) => e.title === "E2E Wizard Course");
|
||||
expect(created?.scheduleType).toBe("sessions");
|
||||
expect(created?.sessions?.filter((s) => !s.cancelled)).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("self-service kiosk: a visitor registers for a free event", async ({ page }) => {
|
||||
await page.goto("/self-service");
|
||||
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
|
||||
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
|
||||
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
|
||||
await page.locator("select").selectOption(value!);
|
||||
await page.getByRole("button", { name: "Start Kiosk" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
|
||||
await page.getByPlaceholder("john@example.com").fill("walkin@e2e.test");
|
||||
await page.getByPlaceholder("+27 82 000 0000").fill("0820000099");
|
||||
await page.getByPlaceholder("John Smith").fill("Walk In Visitor");
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "You're Registered!" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("admin: background jobs page", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/settings?tab=jobs");
|
||||
await expect(page.getByRole("heading", { name: "Background jobs" })).toBeVisible();
|
||||
// The e2e backend runs with JOBS_ENABLED=false.
|
||||
await expect(page.getByText(/All background jobs are switched off/)).toBeVisible();
|
||||
await expect(page.getByText("Session reminders and certificates")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// The e2e database: E2E_DATABASE_URL, or backend/.env's DATABASE_URL with "_e2e" added
|
||||
// to the database name — always separate from the database you develop against.
|
||||
const path = require('path');
|
||||
|
||||
function e2eDatabaseUrl() {
|
||||
if (process.env.E2E_DATABASE_URL) return process.env.E2E_DATABASE_URL;
|
||||
const backend = path.join(__dirname, '..', '..', 'backend');
|
||||
require(require.resolve('dotenv', { paths: [backend] })).config({ path: path.join(backend, '.env'), quiet: true });
|
||||
if (!process.env.DATABASE_URL) throw new Error('Set E2E_DATABASE_URL (or DATABASE_URL in backend/.env) for the e2e tests.');
|
||||
const u = new URL(process.env.DATABASE_URL);
|
||||
const name = u.pathname.replace(/^\//, '');
|
||||
u.pathname = `/${name.endsWith('_e2e') ? name : `${name}_e2e`}`;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
module.exports = { e2eDatabaseUrl };
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Prepares the end-to-end test database, then exits. Run before the e2e backend starts
|
||||
* (see playwright.config.ts): drops and recreates the database, applies the migrations,
|
||||
* and seeds settings, users and events. Writes e2e/.data/seed.json with the ids and
|
||||
* login tokens the tests use.
|
||||
*
|
||||
* Database: E2E_DATABASE_URL, or backend/.env's DATABASE_URL with "_e2e" added to the
|
||||
* database name. It is always a separate database — never the one you develop against.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const backend = path.join(root, 'backend');
|
||||
const req = (m) => require(require.resolve(m, { paths: [backend] }));
|
||||
req('dotenv').config({ path: path.join(backend, '.env') });
|
||||
|
||||
const { e2eDatabaseUrl } = require('./db-url');
|
||||
|
||||
async function recreateDatabase(url) {
|
||||
const { Client } = req('pg');
|
||||
const target = new URL(url);
|
||||
const dbName = decodeURIComponent(target.pathname.replace(/^\//, ''));
|
||||
if (!/_e2e$|^e2e|test/i.test(dbName)) throw new Error(`Refusing to reset "${dbName}" — the e2e database name must contain "e2e" or "test".`);
|
||||
const admin = new URL(url);
|
||||
admin.pathname = '/postgres';
|
||||
admin.search = '';
|
||||
const client = new Client({ connectionString: admin.toString() });
|
||||
await client.connect();
|
||||
await client.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
|
||||
await client.query(`CREATE DATABASE "${dbName}"`);
|
||||
await client.end();
|
||||
}
|
||||
|
||||
async function seed(url) {
|
||||
process.env.DATABASE_URL = url;
|
||||
const prisma = require(path.join(backend, 'src/config/db'));
|
||||
const { generateToken, hashPassword } = require(path.join(backend, 'src/config/auth'));
|
||||
const { generateTicketsForRegistration } = require(path.join(backend, 'src/utils/ticketUtils'));
|
||||
|
||||
await prisma.appSetting.createMany({
|
||||
data: [
|
||||
{ key: 'setup_complete', value: 'true' },
|
||||
{ key: 'org_name', value: 'E2E Church' },
|
||||
{ key: 'org_address', value: '1 Test Street, Pretoria' },
|
||||
],
|
||||
});
|
||||
|
||||
const password = await hashPassword('E2e-password-123');
|
||||
const mk = (role, name, email, phoneNumber) => prisma.user.create({
|
||||
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date() },
|
||||
});
|
||||
const users = {
|
||||
admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'),
|
||||
supervisor: await mk('supervisor', 'Sam Supervisor', 'supervisor@e2e.test', '0820000002'),
|
||||
staff: await mk('staff', 'Stan Staff', 'staff@e2e.test', '0820000003'),
|
||||
user: await mk('user', 'Thandi Test', 'user@e2e.test', '0820000004'),
|
||||
};
|
||||
|
||||
const D = 86400000;
|
||||
const now = Date.now();
|
||||
const at = (days, hourUtc = 16, minute = 30) => {
|
||||
const d = new Date(now + days * D);
|
||||
d.setUTCHours(hourUtc, minute, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
const free = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Free Evening', description: 'A free evening for the tests.', startDate: at(1), endDate: at(1, 19),
|
||||
price: 0, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
|
||||
},
|
||||
});
|
||||
const paid = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Paid Conference', description: 'A paid one-day conference.', startDate: at(3, 7), endDate: at(3, 15),
|
||||
price: 150, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
eventOptions: { create: [{ name: 'Conference ticket', price: 150, isMainTicket: true }] },
|
||||
},
|
||||
});
|
||||
|
||||
// Multi-day: week 1 was a week ago, week 2 is in progress now, then three more weeks.
|
||||
const todayStart = new Date(now - 30 * 60 * 1000);
|
||||
const sessions = [-7, 0, 7, 14, 21].map((days, i) => {
|
||||
const startsAt = new Date(todayStart.getTime() + days * D);
|
||||
return { label: `Week ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 90 * 60 * 1000), order: i };
|
||||
});
|
||||
const course = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Alpha Course', description: 'Five weekly evenings.', startDate: sessions[0].startsAt, endDate: sessions[4].endsAt,
|
||||
price: 300, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
scheduleType: 'sessions', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
|
||||
certificatesEnabled: true,
|
||||
certificateConfig: { template: 'classic', title: 'Certificate of Completion', body: '{name} completed {event}.', rule: { type: 'count', value: 2 } },
|
||||
sessions: { create: sessions },
|
||||
eventOptions: { create: [{ name: 'Course ticket', price: 300, isMainTicket: true }] },
|
||||
},
|
||||
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
|
||||
});
|
||||
await prisma.event.update({ where: { id: course.id }, data: { lateJoinUntilSessionId: course.sessions[2].id } });
|
||||
|
||||
// A drop-in class: three upcoming classes, booked per class (the first is full).
|
||||
const classes = [1, 8, 15].map((days, i) => {
|
||||
const startsAt = at(days, 16, 0);
|
||||
return { label: `Class ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 60 * 60 * 1000), order: i, capacity: i === 0 ? 1 : null };
|
||||
});
|
||||
const dropIn = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Drop-in Class', description: 'Come to the classes you like.', startDate: classes[0].startsAt, endDate: classes[2].endsAt,
|
||||
price: 50, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
scheduleType: 'sessions', attendanceMode: 'pick', waitlistEnabled: true,
|
||||
sessions: { create: classes },
|
||||
eventOptions: { create: [{ name: 'Drop-in', price: 50, isMainTicket: true, sessionMode: 'pick' }] },
|
||||
},
|
||||
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
|
||||
});
|
||||
// A conference sold per pass: people tick the days they'll come, one price for the pass.
|
||||
const days = [2, 3, 4].map((d, i) => {
|
||||
const startsAt = at(d, 7, 0);
|
||||
return { label: `Day ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 8 * 60 * 60 * 1000), order: i };
|
||||
});
|
||||
const conference = await prisma.event.create({
|
||||
data: {
|
||||
title: 'E2E Conference', description: 'Three days; come to the days you can.', startDate: days[0].startsAt, endDate: days[2].endsAt,
|
||||
price: 200, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
|
||||
scheduleType: 'sessions', attendanceMode: 'pick',
|
||||
sessions: { create: days },
|
||||
eventOptions: { create: [{ name: 'Conference pass', price: 200, isMainTicket: true, sessionMode: 'all' }] },
|
||||
},
|
||||
});
|
||||
|
||||
// Someone else already has the only place in class 1.
|
||||
await prisma.registration.create({
|
||||
data: {
|
||||
userId: users.staff.id, eventId: dropIn.id, status: 'paid',
|
||||
registrationOptions: { create: [{ eventOptionId: dropIn.eventOptions[0].id, quantity: 1, priceSnapshot: 50, sessionId: dropIn.sessions[0].id, tranches: { create: [{ quantity: 1, priceSnapshot: 50 }] } }] },
|
||||
},
|
||||
});
|
||||
|
||||
// Thandi is booked on the course and was checked in for week 1.
|
||||
const reg = await prisma.registration.create({
|
||||
data: {
|
||||
userId: users.user.id, eventId: course.id, status: 'paid',
|
||||
registrationOptions: { create: [{ eventOptionId: course.eventOptions[0].id, quantity: 1, priceSnapshot: 300, tranches: { create: [{ quantity: 1, priceSnapshot: 300 }] } }] },
|
||||
},
|
||||
});
|
||||
await prisma.payment.create({ data: { amount: 300, method: 'cash', userId: users.user.id, registrationId: reg.id, eventId: course.id, recordedById: users.supervisor.id } });
|
||||
await generateTicketsForRegistration(reg.id);
|
||||
const ticket = await prisma.ticket.findFirst({ where: { eventId: course.id } });
|
||||
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: users.staff.id, sessionId: course.sessions[0].id, scannedAt: course.sessions[0].startsAt } });
|
||||
|
||||
const out = {
|
||||
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
|
||||
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id },
|
||||
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
|
||||
};
|
||||
fs.mkdirSync(path.join(root, 'e2e', '.data'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'e2e', '.data', 'seed.json'), JSON.stringify(out, null, 2));
|
||||
await prisma.$disconnect?.();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const url = e2eDatabaseUrl();
|
||||
await recreateDatabase(url);
|
||||
execSync('npx prisma migrate deploy', { cwd: backend, env: { ...process.env, DATABASE_URL: url }, stdio: 'ignore' });
|
||||
await seed(url);
|
||||
console.log('[e2e] Database ready:', new URL(url).pathname.slice(1));
|
||||
process.exit(0);
|
||||
})().catch((e) => {
|
||||
console.error('[e2e] Preparing the database failed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// The end-to-end tests run their own dev server; a separate build folder keeps it
|
||||
// from clashing with one already running.
|
||||
distDir: process.env.NEXT_DIST_DIR || ".next",
|
||||
images: {
|
||||
domains: ['images.unsplash.com'],
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.33.0",
|
||||
"version": "1.35.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.33.0",
|
||||
"version": "1.35.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.33.0",
|
||||
"version": "1.35.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
+17
-9
@@ -1,14 +1,18 @@
|
||||
/*
|
||||
* Service worker for offline ticket scanning.
|
||||
*
|
||||
* Only the scanner page and the static files it needs are cached:
|
||||
* - /_next/static/* (hashed, never change): cache-first.
|
||||
* - The scanner page itself: network-first, falling back to the cached copy offline.
|
||||
* Only the scanner page and the static files it needs are cached, both network-first
|
||||
* with the cached copy as the offline fallback:
|
||||
* - /_next/static/*. Not cache-first: under `next dev` these files keep their names when
|
||||
* their content changes, so cache-first served stale code on every page, for good.
|
||||
* (Production files are hashed and come from the browser's HTTP cache anyway.)
|
||||
* - The scanner page itself.
|
||||
* - The page can ask (postMessage { type: 'cache-urls', urls }) for the files it has
|
||||
* loaded to be cached up front, so it works offline straight after "Enable offline mode".
|
||||
* API calls are never cached — offline scans go through IndexedDB instead.
|
||||
*/
|
||||
const CACHE = 'hope-scanner-v1';
|
||||
// v2: drops v1 caches, which could hold stale development scripts (see above).
|
||||
const CACHE = 'hope-scanner-v2';
|
||||
const SCANNER_PATH = '/dashboard/staff/ticket-scanning';
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
@@ -41,11 +45,15 @@ self.addEventListener('fetch', (event) => {
|
||||
if (url.pathname.startsWith('/_next/static/')) {
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const hit = await cache.match(req);
|
||||
if (hit) return hit;
|
||||
const res = await fetch(req);
|
||||
if (res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
try {
|
||||
const res = await fetch(req);
|
||||
if (res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
} catch {
|
||||
const hit = await cache.match(req);
|
||||
if (hit) return hit;
|
||||
throw new Error('offline and not cached');
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,17 +6,18 @@ import { useSearchParams } from "next/navigation";
|
||||
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, type LucideIcon } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, type LucideIcon } from "lucide-react";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
import { JobsTab } from "@/components/admin/JobsTab";
|
||||
import { YocoTab } from "@/components/admin/YocoTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups";
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups" | "jobs";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -27,6 +28,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
|
||||
{ id: "yoco", label: "Yoco", icon: CreditCard },
|
||||
{ id: "backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ id: "jobs", label: "Background jobs", icon: Timer },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
@@ -705,6 +707,7 @@ function SiteSettingsPageInner() {
|
||||
|
||||
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
|
||||
</div>
|
||||
|
||||
{activeTab === "branding" && (
|
||||
|
||||
@@ -15,6 +15,8 @@ import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type Lu
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
||||
import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPrint";
|
||||
import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
|
||||
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
|
||||
@@ -95,6 +97,11 @@ export default function AtTheDoorPage() {
|
||||
const [eventId, setEventId] = useState("");
|
||||
|
||||
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
|
||||
const [doorHasPickOptions, setDoorHasPickOptions] = useState(false);
|
||||
// "Pick sessions" events: the event (for its sessions) and the sessions chosen per
|
||||
// quantity key. Picked sessions are booked on new registrations only.
|
||||
const [doorEvent, setDoorEvent] = useState<Event | null>(null);
|
||||
const [picks, setPicks] = useState<Record<string, string[]>>({});
|
||||
const [showOptionsModal, setShowOptionsModal] = useState(false);
|
||||
const [pendingUser, setPendingUser] = useState<NewAttendee | null>(null);
|
||||
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
|
||||
@@ -157,12 +164,16 @@ export default function AtTheDoorPage() {
|
||||
authToken: token
|
||||
});
|
||||
|
||||
setEventOptions(ev.options || ev.eventOptions || []);
|
||||
const all = ev.options || ev.eventOptions || [];
|
||||
setDoorEvent(ev);
|
||||
setEventOptions(all);
|
||||
setDoorHasPickOptions(choosesSessions(ev));
|
||||
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
|
||||
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
|
||||
setDoorCollectGender(!!ev.collectGender);
|
||||
setDoorBundles(ev.bundles || []);
|
||||
} catch {
|
||||
setDoorEvent(null);
|
||||
setEventOptions([]);
|
||||
setDoorBundles([]);
|
||||
}
|
||||
@@ -206,6 +217,10 @@ export default function AtTheDoorPage() {
|
||||
|
||||
}, [activeRegistration]);
|
||||
|
||||
// Charged per session / has sessions to tick (every ticket on a "pick sessions" event).
|
||||
const isPick = (optionId: string) => isPickOption(doorEvent, eventOptions.find(o => o.id === optionId));
|
||||
const choosing = choosesSessions(doorEvent);
|
||||
|
||||
const confirmRegistration = async () => {
|
||||
if (confirming) return;
|
||||
|
||||
@@ -213,13 +228,18 @@ export default function AtTheDoorPage() {
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(choosing && !pendingEditReg ? { sessionIds: picks[key] || [] } : {}) };
|
||||
});
|
||||
|
||||
if (!opts.length) {
|
||||
setError("Select at least one item");
|
||||
return;
|
||||
}
|
||||
const noSessions = opts.find(o => "sessionIds" in o && o.sessionIds && o.sessionIds.length === 0);
|
||||
if (noSessions) {
|
||||
setError(`Choose the sessions for "${eventOptions.find(o => o.id === noSessions.eventOptionId)?.name || "the ticket"}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, minQty] of Object.entries(minQuantities)) {
|
||||
if (minQty > 0 && (quantities[key] || 0) < minQty) {
|
||||
@@ -237,6 +257,8 @@ export default function AtTheDoorPage() {
|
||||
if (missing) { setError(missing); return; }
|
||||
}
|
||||
|
||||
// Picked-session lines take no names.
|
||||
const nameOptions = eventOptions.filter(o => !isPick(o.id));
|
||||
setConfirming(true);
|
||||
try {
|
||||
if (pendingEditReg) {
|
||||
@@ -279,8 +301,8 @@ export default function AtTheDoorPage() {
|
||||
options: opts,
|
||||
notificationPreference: pendingUser.notifPref,
|
||||
...(doorCoupon ? { couponCode: doorCoupon.code } : {}),
|
||||
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
|
||||
? { attendees: attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
|
||||
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
|
||||
? { attendees: attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
|
||||
: {}),
|
||||
}
|
||||
});
|
||||
@@ -304,16 +326,20 @@ export default function AtTheDoorPage() {
|
||||
// Called when NewAttendeeModal is confirmed — proceed to options selection
|
||||
const handleNewAttendeeConfirm = ({ name, email, phone, notifPref }: { name: string; email: string; phone: string; notifPref: "email" | "whatsapp" | "both" }) => {
|
||||
const qtyMap = initQuantities(eventOptions);
|
||||
// Default main ticket to 1 (first variant if variants exist)
|
||||
const pickMap: Record<string, string[]> = {};
|
||||
// Default main ticket to 1 (first variant if variants exist). On a "pick sessions"
|
||||
// event with a session today that still has room, that's one place in today's session.
|
||||
const today = todaysSession(doorEvent);
|
||||
const todayPick = today && doorEvent && choosing ? eventOptions.find(o => o.isMainTicket
|
||||
&& bookableSessions(doorEvent, o, true).some(s => s.id === today.id && (s.left === null || s.left > 0))) : undefined;
|
||||
eventOptions.forEach(o => {
|
||||
if (!o.isMainTicket) return;
|
||||
if ((o.variants || []).length > 0) {
|
||||
qtyMap[`${o.id}::${(o.variants || [])[0].id}`] = 1;
|
||||
} else {
|
||||
qtyMap[o.id] = 1;
|
||||
}
|
||||
if (!o.isMainTicket || (todayPick ? o !== todayPick : choosing)) return;
|
||||
const key = (o.variants || []).length > 0 ? `${o.id}::${(o.variants || [])[0].id}` : o.id;
|
||||
qtyMap[key] = 1;
|
||||
if (o === todayPick && today) pickMap[key] = [today.id];
|
||||
});
|
||||
setQuantities(qtyMap);
|
||||
setPicks(pickMap);
|
||||
setMinQuantities({});
|
||||
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
|
||||
setPendingEditReg(null);
|
||||
@@ -338,8 +364,8 @@ export default function AtTheDoorPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const qtyMap = initQuantities(eventOptions);
|
||||
const regOptions = freshReg.registrationOptions || [];
|
||||
const qtyMap = initQuantities(eventOptions.filter(o => !isPick(o.id)));
|
||||
const regOptions = (freshReg.registrationOptions || []).filter(ro => !ro.sessionId);
|
||||
regOptions.forEach((ro) => {
|
||||
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
|
||||
if (key in qtyMap) qtyMap[key] = ro.quantity || 0;
|
||||
@@ -354,6 +380,7 @@ export default function AtTheDoorPage() {
|
||||
});
|
||||
|
||||
setQuantities(qtyMap);
|
||||
setPicks({});
|
||||
setMinQuantities(minQtyMap);
|
||||
setPendingEditReg(freshReg);
|
||||
setPendingUser(null);
|
||||
@@ -495,7 +522,8 @@ export default function AtTheDoorPage() {
|
||||
<OptionsModal
|
||||
open={showOptionsModal}
|
||||
onClose={() => { setShowOptionsModal(false); setPendingEditReg(null); }}
|
||||
options={eventOptions}
|
||||
options={pendingEditReg ? eventOptions.filter(o => !isPick(o.id)) : eventOptions}
|
||||
pick={doorEvent && doorHasPickOptions ? { event: doorEvent, picks, setPicks } : null}
|
||||
quantities={quantities}
|
||||
setQuantities={setQuantities}
|
||||
minQuantities={minQuantities}
|
||||
@@ -1045,10 +1073,14 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-day events: check-in is per session, so only today's session's check-ins count.
|
||||
const session = todaysSession(ticketsForEvent[0]?.event);
|
||||
const myTickets = ticketsForEvent
|
||||
.filter(t => t.registrationOption?.registration?.id === registration.id)
|
||||
.map(t => {
|
||||
const totalRedeemed = (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const totalRedeemed = (t.usages || [])
|
||||
.filter(u => !session || u.sessionId === session.id)
|
||||
.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (t.quantity || 1) - totalRedeemed;
|
||||
return { ...t, totalRedeemed, remaining };
|
||||
});
|
||||
@@ -1080,7 +1112,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
|
||||
return (
|
||||
<div className="border rounded-xl p-6 bg-white shadow-sm">
|
||||
|
||||
<div className="text-xl font-semibold mb-4">Check-In</div>
|
||||
<div className="text-xl font-semibold mb-4">Check-In{session ? <span className="ml-2 text-sm font-normal text-gray-500">{session.label || "Today's session"}</span> : null}</div>
|
||||
|
||||
{/* ✅ User */}
|
||||
<div className="mb-4">
|
||||
@@ -1178,15 +1210,30 @@ type OptionsModalProps = {
|
||||
bundles: PublicBundle[];
|
||||
// Named-attendee editor settings, or null when the event doesn't take names.
|
||||
names: Omit<React.ComponentProps<typeof AttendeeNamesEditor>, "options" | "qtyFor" | "payerLabel"> | null;
|
||||
// A "pick sessions" event: its sessions and the sessions chosen per quantity key.
|
||||
pick: { event: Event; picks: Record<string, string[]>; setPicks: React.Dispatch<React.SetStateAction<Record<string, string[]>>> } | null;
|
||||
};
|
||||
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, bundles, names }: OptionsModalProps) {
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, bundles, names, pick }: OptionsModalProps) {
|
||||
const pickOpt = (optionId: string) => !!pick && isPickOption(pick.event, (options || []).find(o => o.id === optionId));
|
||||
// Sessions are ticked on new bookings only (edits leave booked sessions as they are).
|
||||
const choiceOpt = (optionId: string) => !!pick && !isEdit && (options || []).some(o => o.id === optionId);
|
||||
// A picked option is priced per session.
|
||||
const times = (key: string) => (pick && pickOpt(key.split("::")[0]) ? (pick.picks[key] || []).length : 1);
|
||||
const basket = Object.entries(quantities)
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, variantId: variantId || null };
|
||||
return { eventOptionId, quantity, variantId: variantId || null, ...(pick && choiceOpt(eventOptionId) ? { sessionIds: pick.picks[key] || [] } : {}) };
|
||||
});
|
||||
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
|
||||
const picker = (key: string, option: EventOption) => {
|
||||
if (!pick || !choiceOpt(option.id)) return null;
|
||||
const qty = quantities[key] || 0;
|
||||
return <SessionPicker event={pick.event} option={option} staff quantity={Math.max(1, qty)} selected={qty > 0 ? pick.picks[key] || [] : []}
|
||||
onChange={ids => { pick.setPicks(p => ({ ...p, [key]: ids })); if (qty === 0 && ids.length > 0) setQuantities(q => ({ ...q, [key]: 1 })); }} />;
|
||||
};
|
||||
const perSession = (o: EventOption) => (pickOpt(o.id) ? " per session" : "");
|
||||
// Bundle deals apply to the whole registration, so this holds for edits too.
|
||||
const bundleQuote = useBundleQuote({ eventId, basket, token, forUserId: couponForUserId, enabled: open });
|
||||
|
||||
@@ -1197,9 +1244,9 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
const total = safeOptions.reduce((sum, o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
return sum + (o.variants || []).reduce((vs, v) =>
|
||||
vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
|
||||
vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * variantUnitNow(o, v), 0);
|
||||
}
|
||||
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
|
||||
return sum + (quantities[o.id] || 0) * times(o.id) * optionUnitNow(o);
|
||||
}, 0);
|
||||
|
||||
const afterBundles = bundleQuote ? bundleQuote.total : total;
|
||||
@@ -1220,6 +1267,11 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
<div className="text-xl font-semibold">
|
||||
{isEdit ? "Edit Registration" : "Select Items"}
|
||||
</div>
|
||||
{isEdit && pick && (
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
Sessions already booked stay as they are. To add sessions, book them as a new registration.
|
||||
</div>
|
||||
)}
|
||||
{isEdit && totalPaid > 0 && (
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
Already paid: <strong>R {Number(totalPaid).toFixed(2)}</strong> — new total must be at least this amount.
|
||||
@@ -1251,11 +1303,12 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
const key = `${opt.id}::${v.id}`;
|
||||
const minQty = minQuantities[key] || 0;
|
||||
return (
|
||||
<div key={v.id} className="flex items-center justify-between px-4 py-2.5 border-b last:border-b-0">
|
||||
<div key={v.id} className="px-4 py-2.5 border-b last:border-b-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm">{v.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
R {unit.toFixed(2)}
|
||||
R {unit.toFixed(2)}{perSession(opt)}
|
||||
{unit < basePrice && basePrice > 0 && <span className="ml-1 text-green-600">(early bird)</span>}
|
||||
</div>
|
||||
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
|
||||
@@ -1266,6 +1319,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
<button onClick={() => stepper(key, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
|
||||
</div>
|
||||
</div>
|
||||
{picker(key, opt)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1277,7 +1332,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
<div key={opt.id} className="border rounded-xl p-3">
|
||||
<div className="font-medium">{opt.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
R {unit.toFixed(2)}
|
||||
R {unit.toFixed(2)}{perSession(opt)}
|
||||
{unit < opt.price && opt.price > 0 && <span className="ml-1 text-green-600">(early bird, was R {opt.price.toFixed(2)})</span>}
|
||||
{opt.isMainTicket ? " • Main" : ""}
|
||||
</div>
|
||||
@@ -1287,6 +1342,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
<div className="w-6 text-center text-lg font-semibold">{quantities[opt.id] || 0}</div>
|
||||
<button onClick={() => stepper(opt.id, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
|
||||
</div>
|
||||
{picker(opt.id, opt)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1307,7 +1363,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
{names && (
|
||||
<div className="mb-3 max-h-64 overflow-y-auto">
|
||||
<AttendeeNamesEditor
|
||||
options={safeOptions}
|
||||
options={safeOptions.filter(o => !pickOpt(o.id))}
|
||||
qtyFor={(o: string, v: string | null) => quantities[v ? `${o}::${v}` : o] || 0}
|
||||
{...names}
|
||||
payerLabel="the person registering"
|
||||
|
||||
@@ -91,7 +91,9 @@ function AttendancePageInner() {
|
||||
const key = `${row.ticketId}:${sessionId}`;
|
||||
setPending(key);
|
||||
try {
|
||||
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId: row.ticketId, sessionId, present } });
|
||||
// Picked sessions have a ticket per session.
|
||||
const ticketId = row.sessionTickets?.[sessionId] || row.ticketId;
|
||||
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId, sessionId, present } });
|
||||
const col = reg?.columns.find(c => c.id === sessionId);
|
||||
setInfo(`${row.name || "Attendee"} marked ${present ? "present" : "absent"}${multi && col ? ` for ${col.label}` : ""}.`);
|
||||
await load();
|
||||
@@ -229,6 +231,9 @@ function AttendancePageInner() {
|
||||
{columns.map(c => {
|
||||
const qty = r.checkIns[c.id] || 0;
|
||||
const key = `${r.ticketId}:${c.id}`;
|
||||
if (r.bookedSessions && !r.bookedSessions.includes(c.id)) {
|
||||
return <td key={c.id} className="px-2 py-2 text-center text-gray-300" title="Not booked for this session">—</td>;
|
||||
}
|
||||
const disabled = !canManage || c.cancelled || pending !== null || reg.event.cashupStatus === "closed";
|
||||
return (
|
||||
<td key={c.id} className="px-2 py-2 text-center">
|
||||
|
||||
@@ -95,6 +95,7 @@ type OptionDraft = {
|
||||
staffPrice?: string; // empty → no staff price
|
||||
earlyBirdTiers: Tier[]; // option-level (used only when no variants)
|
||||
variants: Variant[];
|
||||
sessionMode?: "all" | "pick"; // "pick sessions" events only
|
||||
};
|
||||
|
||||
type SectionDraft = {
|
||||
@@ -209,7 +210,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
|
||||
// ─── Items & Pricing substep editors ────────────────────────────────────────
|
||||
|
||||
/** Substep 0 — bare option rows */
|
||||
function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void; required?: boolean }) {
|
||||
function OptionsEditor({ options, onChange, required, pickMode }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void; required?: boolean; pickMode?: boolean }) {
|
||||
const upd = (i: number, patch: Partial<OptionDraft>) => { const c = options.slice(); c[i] = { ...c[i], ...patch }; onChange(c); };
|
||||
const rem = (i: number) => { const c = options.slice(); c.splice(i, 1); onChange(c); };
|
||||
const add = () => {
|
||||
@@ -245,6 +246,15 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
|
||||
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24" placeholder="(none)"
|
||||
value={opt.staffPrice ?? ""} onChange={e => upd(i, { staffPrice: e.target.value })} />
|
||||
</div>
|
||||
{pickMode && (
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-500 mb-0.5" title="Pick sessions: bought per session — the price and stock are per session">Sessions</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={opt.sessionMode || "all"} onChange={e => upd(i, { sessionMode: e.target.value as "all" | "pick" })}>
|
||||
<option value="all">All sessions</option>
|
||||
<option value="pick">Pick sessions (price per session)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 pb-1">
|
||||
<label className="text-xs flex items-center gap-1 cursor-pointer whitespace-nowrap">
|
||||
<input type="checkbox" checked={opt.isMainTicket} onChange={e => upd(i, { isMainTicket: e.target.checked })} /> Main ticket
|
||||
@@ -826,6 +836,7 @@ interface EventDraft {
|
||||
allowTicketTransfer: boolean; collectGender: boolean;
|
||||
allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean;
|
||||
scheduleType: "single" | "sessions";
|
||||
attendanceMode: "whole" | "pick";
|
||||
}
|
||||
|
||||
const blankDraft = (location = ""): EventDraft => ({
|
||||
@@ -840,6 +851,7 @@ const blankDraft = (location = ""): EventDraft => ({
|
||||
allowTicketTransfer: true, collectGender: false,
|
||||
allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true,
|
||||
scheduleType: "single",
|
||||
attendanceMode: "whole",
|
||||
});
|
||||
|
||||
// "YYYY-MM-DDTHH:MM" (local) → ISO
|
||||
@@ -886,6 +898,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||
location: ev.location || "",
|
||||
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
|
||||
attendanceMode: ev.attendanceMode === "pick" ? "pick" : "whole",
|
||||
} : blankDraft(settings.org_address || ""));
|
||||
|
||||
// ── multi-day schedule + attendance settings ──
|
||||
@@ -904,6 +917,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
}));
|
||||
const updAttendance = (patch: Partial<AttendanceDraft>) => setAttendance(a => ({ ...a, ...patch }));
|
||||
const multi = draft.scheduleType === "sessions";
|
||||
const pickMode = multi && draft.attendanceMode === "pick";
|
||||
// A multi-session event's dates come from its sessions.
|
||||
const scheduleBounds = useMemo(() => {
|
||||
const active = sortSessions(sessions).filter(s => !s.cancelled && s.startsAt && s.endsAt);
|
||||
@@ -926,6 +940,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
isMainTicket: !!o.isMainTicket,
|
||||
stockLimit: String(o.stockLimit ?? "0"),
|
||||
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
|
||||
sessionMode: o.sessionMode || "all",
|
||||
// Option-level tiers only (no variantId)
|
||||
earlyBirdTiers: allTiers
|
||||
.filter((t) => !t.variantId)
|
||||
@@ -1075,7 +1090,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// Update option + variants first to get/confirm variant IDs
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${opt.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all", variants: variantsPayload }
|
||||
});
|
||||
|
||||
// Build variant tiers with confirmed IDs from response
|
||||
@@ -1107,7 +1122,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// New option added during edit
|
||||
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
|
||||
});
|
||||
if (created?.id) {
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
|
||||
@@ -1154,13 +1169,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// Update the auto-created main ticket
|
||||
await apiFetch(`/api/events/options/${existingMain.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
|
||||
});
|
||||
optId = existingMain.id;
|
||||
} else {
|
||||
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
|
||||
});
|
||||
optId = created?.id;
|
||||
}
|
||||
@@ -1234,7 +1249,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
endDate: multi && scheduleBounds ? isoOf(scheduleBounds.end) : new Date(draft.endDate).toISOString(),
|
||||
scheduleType: draft.scheduleType,
|
||||
...(multi ? {
|
||||
sessions: sortedSessions.map(s => ({ id: s.id, label: s.label.trim() || null, startsAt: isoOf(s.startsAt), endsAt: isoOf(s.endsAt), location: s.location.trim() || null, cancelled: s.cancelled })),
|
||||
attendanceMode: draft.attendanceMode,
|
||||
sessions: sortedSessions.map(s => ({ id: s.id, label: s.label.trim() || null, startsAt: isoOf(s.startsAt), endsAt: isoOf(s.endsAt), location: s.location.trim() || null, cancelled: s.cancelled, capacity: pickMode && s.capacity.trim() ? parseInt(s.capacity, 10) : null })),
|
||||
lateJoinEnabled: attendance.lateJoinEnabled,
|
||||
lateJoinUntilSessionIndex: attendance.lateJoinEnabled ? sortedSessions.findIndex(s => s.key === attendance.lateJoinUntilKey) : -1,
|
||||
latePricing: attendance.latePricing,
|
||||
@@ -1437,7 +1453,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
{multi && <p className="text-[10px] text-gray-400 mt-1">One registration and one ticket for every session, e.g. every Wednesday for 5 weeks. Check-in is per session.</p>}
|
||||
</div>
|
||||
{multi ? (
|
||||
<SessionsEditor sessions={sessions} onChange={setSessions} defaultLocation={draft.location} />
|
||||
<>
|
||||
<SessionsEditor sessions={sessions} onChange={setSessions} defaultLocation={draft.location} showCapacity={pickMode} />
|
||||
<div>
|
||||
<div className="block text-xs text-gray-600 mb-1">How people book</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-start gap-2 text-sm"><input type="radio" className="mt-1" name="attendance-mode" checked={draft.attendanceMode === "whole"} onChange={() => upd({ attendanceMode: "whole" })} /><span><span className="font-medium">The whole course</span><span className="block text-xs text-gray-500">One ticket for every session.</span></span></label>
|
||||
<label className="flex items-start gap-2 text-sm"><input type="radio" className="mt-1" name="attendance-mode" disabled={draft.requiresParentalConsent} checked={draft.attendanceMode === "pick"} onChange={() => upd({ attendanceMode: "pick" })} /><span><span className="font-medium">Pick sessions</span><span className="block text-xs text-gray-500">People choose which sessions to come to (e.g. day passes or a drop-in class). Set which tickets are per session in Items & Pricing, and an optional capacity per session above.{draft.requiresParentalConsent ? " Not available on youth events." : ""}</span></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<DTInput label="Start" required value={draft.startDate} onChange={v => upd({ startDate: v })} />
|
||||
@@ -1506,7 +1531,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
{step === 1 && pricingSubstep === 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-3">Define your ticket types or purchasable items. Variants and early-bird pricing are configured in the next two steps.</p>
|
||||
<OptionsEditor options={options} onChange={handleOptionsChange} required={mode === "create"} />
|
||||
<OptionsEditor options={options} onChange={handleOptionsChange} required={mode === "create"} pickMode={pickMode} />
|
||||
</div>
|
||||
)}
|
||||
{step === 1 && pricingSubstep === 1 && (
|
||||
|
||||
@@ -12,6 +12,8 @@ import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload,
|
||||
import { UserPlus, CreditCard } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
||||
import { choosesSessions, isPickOption } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
@@ -268,29 +270,53 @@ export default function ManualRegistrationPage() {
|
||||
}
|
||||
}, [selectedEventId, events]);
|
||||
|
||||
// The events list doesn't carry bundle deals; the single-event response does.
|
||||
// The events list doesn't carry bundle deals or places per session; the single-event
|
||||
// response does.
|
||||
const [fullEvent, setFullEvent] = useState<Event | null>(null);
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) { setBundles([]); return; }
|
||||
if (!selectedEventId) { setBundles([]); setFullEvent(null); return; }
|
||||
let cancelled = false;
|
||||
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: token || undefined })
|
||||
.then(ev => { if (!cancelled) setBundles(ev?.bundles || []); })
|
||||
.catch(() => { if (!cancelled) setBundles([]); });
|
||||
.then(ev => { if (!cancelled) { setBundles(ev?.bundles || []); setFullEvent(ev || null); } })
|
||||
.catch(() => { if (!cancelled) { setBundles([]); setFullEvent(null); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedEventId, token]);
|
||||
|
||||
// "Pick sessions" options: the sessions chosen per quantity key.
|
||||
const [picks, setPicks] = useState<Record<string, string[]>>({});
|
||||
useEffect(() => { setPicks({}); }, [selectedEventId]);
|
||||
const pickOpt = (optionId: string) => {
|
||||
const o = (fullEvent?.eventOptions || []).find(x => x.id === optionId);
|
||||
return fullEvent && o && isPickOption(fullEvent, o) ? o : null;
|
||||
};
|
||||
// On a "pick sessions" event every ticket has sessions to tick; only pickOpt ones are charged per session.
|
||||
const choiceOpt = (optionId: string) => {
|
||||
const o = (fullEvent?.eventOptions || []).find(x => x.id === optionId);
|
||||
return fullEvent && o && choosesSessions(fullEvent) ? o : null;
|
||||
};
|
||||
const times = (key: string) => (pickOpt(key.split("::")[0]) ? (picks[key] || []).length : 1);
|
||||
const picker = (key: string, optionId: string) => {
|
||||
const o = choiceOpt(optionId);
|
||||
if (!o || !fullEvent) return null;
|
||||
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
|
||||
const qty = quantities[key] || 0;
|
||||
return <SessionPicker event={fullEvent} option={o} staff quantity={Math.max(1, qty)} selected={qty > 0 ? picks[key] || [] : []}
|
||||
onChange={ids => { setPicks(p => ({ ...p, [key]: ids })); if (qty === 0 && ids.length > 0) setQuantities(q => ({ ...q, [key]: 1 })); }} />;
|
||||
};
|
||||
|
||||
const totalDue = useMemo(() => {
|
||||
return options.reduce((sum, o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
|
||||
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * variantUnitNow(o, v), 0);
|
||||
}
|
||||
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
|
||||
return sum + (quantities[o.id] || 0) * times(o.id) * optionUnitNow(o);
|
||||
}, 0);
|
||||
}, [options, quantities]);
|
||||
}, [options, quantities, picks, fullEvent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const basket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, variantId: variantId || null };
|
||||
}), [quantities]);
|
||||
return { eventOptionId, quantity, variantId: variantId || null, ...(choiceOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
|
||||
}), [quantities, picks, fullEvent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const bundleQuote = useBundleQuote({ eventId: selectedEventId, basket, token, forUserId: pickedUserId });
|
||||
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : totalDue;
|
||||
|
||||
@@ -304,9 +330,11 @@ export default function ManualRegistrationPage() {
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(choiceOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
|
||||
});
|
||||
if (opts.length === 0) { setError("Please select at least one ticket option."); return; }
|
||||
const noSessions = opts.find(o => "sessionIds" in o && o.sessionIds && o.sessionIds.length === 0);
|
||||
if (noSessions) { setError(`Choose the sessions for "${options.find(o => o.id === noSessions.eventOptionId)?.name || "the ticket"}".`); return; }
|
||||
if (selectedIsYouth) {
|
||||
const missing = consentDetailsMissing(options, manualQtyFor, attendeeEntries, selectedEv.startDate);
|
||||
if (missing) { setError(missing); return; }
|
||||
@@ -564,7 +592,8 @@ export default function ManualRegistrationPage() {
|
||||
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||
const key = `${opt.id}::${v.id}`;
|
||||
return (
|
||||
<div key={v.id} className="flex items-center justify-between px-3 py-2 border-b last:border-b-0">
|
||||
<div key={v.id} className="border-b last:border-b-0">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm">{v.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
@@ -582,6 +611,8 @@ export default function ManualRegistrationPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{picker(key, opt.id) && <div className="px-3 pb-2">{picker(key, opt.id)}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -605,6 +636,7 @@ export default function ManualRegistrationPage() {
|
||||
onChange={e => setQuantities(q => ({ ...q, [opt.id]: Math.max(0, parseInt(e.target.value || '0')) }))}
|
||||
/>
|
||||
</div>
|
||||
{picker(opt.id, opt.id)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { entryLineName } from "@/lib/waitlist";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
@@ -22,6 +23,7 @@ type Entry = {
|
||||
createdAt: string;
|
||||
eventOption: { name: string };
|
||||
variant: { name: string } | null;
|
||||
session: { label: string | null; startsAt: string } | null;
|
||||
user: { name: string; email: string; phoneNumber: string | null };
|
||||
};
|
||||
|
||||
@@ -153,7 +155,7 @@ export default function WaitlistPage() {
|
||||
<div className="text-sm font-medium">{e.user.name}</div>
|
||||
<div className="text-xs text-gray-500">{[e.user.email, e.user.phoneNumber].filter(Boolean).join(" · ")}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</TableCell>
|
||||
<TableCell className="text-sm">{entryLineName(e)}</TableCell>
|
||||
<TableCell className="text-sm">{e.quantity}{e.status === "offered" && e.offeredQuantity !== e.quantity ? ` (${e.offeredQuantity} offered)` : ""}</TableCell>
|
||||
<TableCell className="text-xs text-gray-600">{formatDate(new Date(e.createdAt))}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import { BundleSavings } from "@/components/shared/BundleDeals";
|
||||
import { MyCertificates } from "@/components/events/MyCertificates";
|
||||
import { isPickOption } from "@/lib/sessions";
|
||||
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
|
||||
|
||||
// Helper formatters
|
||||
@@ -484,8 +485,13 @@ export default function UserDashboardPage() {
|
||||
return !isEventOver(activeReg.event);
|
||||
}, [activeReg]);
|
||||
|
||||
// Picked-session lines can't be edited here (book more by registering), but still count.
|
||||
const pickedLinesTotal = useMemo(() => (activeReg?.registrationOptions || [])
|
||||
.filter(ro => ro.sessionId)
|
||||
.reduce((s, ro) => s + (ro.lineTotal ?? (ro.quantity || 0) * (ro.priceSnapshot || 0)), 0), [activeReg]);
|
||||
|
||||
const editTotal = useMemo(() => {
|
||||
return editEventOptions.reduce((sum, eo) => {
|
||||
return pickedLinesTotal + editEventOptions.reduce((sum, eo) => {
|
||||
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
|
||||
return sum + eo.variants.reduce((vsum, v) => {
|
||||
const qty = editQuantities[`${eo.id}:${v.id}`] || 0;
|
||||
@@ -494,7 +500,7 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
return sum + (editQuantities[eo.id] || 0) * effectiveEventOptionUnitPrice(eo, null);
|
||||
}, 0);
|
||||
}, [editEventOptions, editQuantities]);
|
||||
}, [editEventOptions, editQuantities, pickedLinesTotal]);
|
||||
|
||||
const beginEdit = async () => {
|
||||
if (!activeReg || !token) return;
|
||||
@@ -503,13 +509,13 @@ export default function UserDashboardPage() {
|
||||
try {
|
||||
// Load event options (with variants) for this event
|
||||
const eventData = await apiFetch<Event>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
|
||||
const eventOptions = eventData?.eventOptions || [];
|
||||
const eventOptions = (eventData?.eventOptions || []).filter((eo) => !isPickOption(eventData, eo));
|
||||
|
||||
const regOptById = new Map<string, RegistrationOption>((activeReg.registrationOptions || []).map((o) => [o.id, o]));
|
||||
|
||||
// Prefill quantities from the current registration
|
||||
const qtyMap: Record<string, number> = {};
|
||||
for (const ro of (activeReg.registrationOptions || [])) {
|
||||
for (const ro of (activeReg.registrationOptions || []).filter(r => !r.sessionId)) {
|
||||
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
|
||||
qtyMap[key] = (qtyMap[key] || 0) + (ro.quantity || 0);
|
||||
}
|
||||
@@ -519,7 +525,7 @@ export default function UserDashboardPage() {
|
||||
const minQtyMap: Record<string, number> = {};
|
||||
for (const t of tickets) {
|
||||
const ro = t.registrationOptionId ? regOptById.get(t.registrationOptionId) : undefined;
|
||||
if (!ro) continue;
|
||||
if (!ro || ro.sessionId) continue;
|
||||
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
|
||||
minQtyMap[key] = (minQtyMap[key] || 0) + (t.quantity || 0);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type EventOption = {
|
||||
stockLimit?: number;
|
||||
availableCount?: number;
|
||||
isMainTicket?: boolean;
|
||||
sessionMode?: "all" | "pick";
|
||||
earlyBirdTiers?: { id: string; deadline: string; price: number; stockLimit?: number }[];
|
||||
variants?: OptionVariant[];
|
||||
};
|
||||
@@ -48,6 +49,7 @@ type Event = {
|
||||
allowSponsorships?: boolean;
|
||||
bundles?: PublicBundle[];
|
||||
scheduleType?: "single" | "sessions";
|
||||
attendanceMode?: "whole" | "pick";
|
||||
sessions?: EventSession[];
|
||||
registrationClosesAt?: string;
|
||||
lateJoin?: LateJoinInfo | null;
|
||||
@@ -58,7 +60,7 @@ import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
import { BundleOffers } from "@/components/shared/BundleDeals";
|
||||
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
|
||||
import { latePrice, registrationClosesAt, scheduleSummary } from "@/lib/sessions";
|
||||
import { isPickOption, latePrice, registrationClosesAt, scheduleSummary } from "@/lib/sessions";
|
||||
import { SessionSchedule } from "@/components/events/SessionSchedule";
|
||||
import { appUrl, appName } from "@/lib/siteConfig";
|
||||
import { getServerSettings } from "@/lib/serverSettings";
|
||||
@@ -292,7 +294,8 @@ function TicketList({ event }: { event: Event }) {
|
||||
}
|
||||
// Late joiners on a multi-day event pay a lower main-ticket price.
|
||||
const late = event.lateJoin?.active ? event.lateJoin : null;
|
||||
const forLate = (opt: EventOption, price: number) => (late && opt.isMainTicket ? latePrice(price, late) : price);
|
||||
const forLate = (opt: EventOption, price: number) => (late && opt.isMainTicket && opt.sessionMode !== "pick" ? latePrice(price, late) : price);
|
||||
const perSession = (opt: EventOption) => (isPickOption(event, opt) ? " per session" : "");
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<BundleOffers bundles={event.bundles} />
|
||||
@@ -316,7 +319,7 @@ function TicketList({ event }: { event: Event }) {
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
|
||||
<span className="font-medium text-sm">{opt.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">{priceLabel}</span>
|
||||
<span className="text-sm text-gray-600">{priceLabel}{perSession(opt)}</span>
|
||||
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
|
||||
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
|
||||
</div>
|
||||
@@ -360,7 +363,7 @@ function TicketList({ event }: { event: Event }) {
|
||||
<span className="text-xs text-brand-600">Early bird</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
|
||||
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}${perSession(opt)}`}</span>
|
||||
)}
|
||||
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
|
||||
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { apiFetch } from "@/lib/api";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
|
||||
import { latePrice, registrationClosesAt } from "@/lib/sessions";
|
||||
import { choosesSessions, isPickOption, latePrice, registrationClosesAt } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
|
||||
import { analyticsSessionId } from "@/lib/analytics";
|
||||
@@ -33,6 +34,8 @@ type EventOption = {
|
||||
isMainTicket?: boolean;
|
||||
earlyBirdTiers?: EarlyBirdTier[];
|
||||
variants?: OptionVariant[];
|
||||
sessionMode?: "all" | "pick";
|
||||
sessionAvailability?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type RegisterEvent = {
|
||||
@@ -54,8 +57,10 @@ export type RegisterEvent = {
|
||||
waitlistEnabled?: boolean;
|
||||
bundles?: PublicBundle[];
|
||||
scheduleType?: "single" | "sessions";
|
||||
attendanceMode?: "whole" | "pick";
|
||||
sessions?: EventSession[];
|
||||
registrationClosesAt?: string;
|
||||
wholeCourseClosesAt?: string | null;
|
||||
lateJoin?: LateJoinInfo | null;
|
||||
};
|
||||
|
||||
@@ -66,7 +71,7 @@ function withLatePrices(event: RegisterEvent): RegisterEvent {
|
||||
if (!late) return event;
|
||||
return {
|
||||
...event,
|
||||
eventOptions: (event.eventOptions || []).map(o => (!o.isMainTicket ? o : {
|
||||
eventOptions: (event.eventOptions || []).map(o => (!o.isMainTicket || o.sessionMode === "pick" ? o : {
|
||||
...o,
|
||||
price: latePrice(o.price, late),
|
||||
earlyBirdTiers: (o.earlyBirdTiers || []).map(t => ({ ...t, price: latePrice(t.price, late) })),
|
||||
@@ -75,7 +80,7 @@ function withLatePrices(event: RegisterEvent): RegisterEvent {
|
||||
};
|
||||
}
|
||||
|
||||
type WaitlistOffer = { eventOptionId: string; variantId: string | null; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
|
||||
type WaitlistOffer = { eventOptionId: string; variantId: string | null; sessionId?: string | null; lineName?: string; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
|
||||
|
||||
// Key format: "optionId" for no-variant options, "optionId:variantId" for variant options
|
||||
type QtyMap = Record<string, number>;
|
||||
@@ -156,18 +161,31 @@ function QtyControl({
|
||||
);
|
||||
}
|
||||
|
||||
// A "pick sessions" event: the sessions chosen per quantity key, and the event's schedule.
|
||||
// perSession: this ticket is charged per session (otherwise one price for the sessions ticked).
|
||||
type PickProps = { event: RegisterEvent; picks: Record<string, string[]>; onPick: (key: string, ids: string[]) => void; perSession: boolean };
|
||||
|
||||
function OptionCard({
|
||||
opt,
|
||||
quantities,
|
||||
onQtyChange,
|
||||
waitlist,
|
||||
pick,
|
||||
}: {
|
||||
opt: EventOption;
|
||||
quantities: QtyMap;
|
||||
onQtyChange: (key: string, val: number) => void;
|
||||
waitlist?: { eventId: string; guestsAllowed: boolean } | null;
|
||||
pick?: PickProps | null;
|
||||
}) {
|
||||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||||
const perSession = pick?.perSession ? " per session" : "";
|
||||
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
|
||||
const picker = (key: string, qty: number, unit: number, variant?: { id: string; name: string }) => (pick ? (
|
||||
<SessionPicker event={pick.event} option={opt} selected={qty > 0 ? pick.picks[key] || [] : []} quantity={Math.max(1, qty)} unitPrice={pick.perSession ? unit : undefined}
|
||||
onChange={(ids) => { pick.onPick(key, ids); if (qty === 0 && ids.length > 0) onQtyChange(key, 1); }}
|
||||
waitlist={waitlist ? { ...waitlist, variantId: variant?.id ?? null, label: variant ? `${opt.name} (${variant.name})` : opt.name } : null} />
|
||||
) : null);
|
||||
const isSoldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
|
||||
|
||||
if (hasVariants) {
|
||||
@@ -177,7 +195,7 @@ function OptionCard({
|
||||
const variantPrices = variants.map((v) => effectiveUnitForVariant(opt, v));
|
||||
const minPrice = Math.min(...variantPrices);
|
||||
const maxPrice = Math.max(...variantPrices);
|
||||
const priceLabel = minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
|
||||
const priceLabel = (minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`) + perSession;
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden bg-white shadow-sm">
|
||||
@@ -214,10 +232,11 @@ function OptionCard({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div key={variant.id} className="flex items-center justify-between px-4 py-2.5">
|
||||
<div key={variant.id} className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm">{variant.name}</span>
|
||||
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}</span>
|
||||
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}{perSession}</span>
|
||||
<StockBadge stockLimit={variant.stockLimit} availableCount={variant.availableCount} />
|
||||
{waitlist && variantSoldOut && !isSoldOut && (
|
||||
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
|
||||
@@ -231,6 +250,8 @@ function OptionCard({
|
||||
max={maxQty}
|
||||
/>
|
||||
</div>
|
||||
{picker(key, qty, unitPrice, variant)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -248,7 +269,8 @@ function OptionCard({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border rounded-lg p-3 bg-white shadow-sm">
|
||||
<div className="border rounded-lg p-3 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1 mr-3">
|
||||
<div className="font-medium text-sm">{opt.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5 flex items-center gap-2 flex-wrap">
|
||||
@@ -260,7 +282,7 @@ function OptionCard({
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>R{price.toFixed(2)}</span>
|
||||
<span>R{price.toFixed(2)}{perSession}</span>
|
||||
)}
|
||||
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
|
||||
{waitlist && isSoldOut && (
|
||||
@@ -275,6 +297,8 @@ function OptionCard({
|
||||
max={maxQty}
|
||||
/>
|
||||
</div>
|
||||
{picker(key, qty, price)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -377,6 +401,10 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
...priced,
|
||||
eventOptions: (priced.eventOptions || []).map(o => {
|
||||
if (o.id !== offer.eventOptionId) return o;
|
||||
if (offer.sessionId) {
|
||||
const left = o.sessionAvailability?.[offer.sessionId];
|
||||
return { ...o, ...(left !== undefined ? { sessionAvailability: { ...o.sessionAvailability, [offer.sessionId]: left + offer.quantity } } : {}) };
|
||||
}
|
||||
return {
|
||||
...o,
|
||||
availableCount: o.availableCount !== undefined ? o.availableCount + offer.quantity : o.availableCount,
|
||||
@@ -432,18 +460,32 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
setQuantities((q) => ({ ...q, [key]: val }));
|
||||
};
|
||||
|
||||
// "Pick sessions" events: the sessions chosen for each quantity key. Every ticket
|
||||
// shows the sessions; per-session tickets are charged once per session ticked.
|
||||
const [picks, setPicks] = useState<Record<string, string[]>>({});
|
||||
const choosing = choosesSessions(event);
|
||||
const pickFor = (opt: EventOption) => isPickOption(event, opt);
|
||||
// A waitlist offer for one session: that session is ticked for them.
|
||||
useEffect(() => {
|
||||
if (!offer?.sessionId) return;
|
||||
const key = offer.variantId ? `${offer.eventOptionId}:${offer.variantId}` : offer.eventOptionId;
|
||||
setPicks(p => ({ ...p, [key]: [offer.sessionId as string] }));
|
||||
}, [offer]);
|
||||
const sessionsFor = (key: string, opt: EventOption) => (pickFor(opt) ? (picks[key] || []).length : 1);
|
||||
|
||||
const total = useMemo(() => {
|
||||
return (event.eventOptions || []).reduce((sum, opt) => {
|
||||
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
|
||||
if (hasVariants) {
|
||||
return sum + opt.variants!.reduce((vsum, v) => {
|
||||
const qty = quantities[`${opt.id}:${v.id}`] || 0;
|
||||
return vsum + qty * effectiveUnitForVariant(opt, v);
|
||||
const key = `${opt.id}:${v.id}`;
|
||||
const qty = quantities[key] || 0;
|
||||
return vsum + qty * sessionsFor(key, opt) * effectiveUnitForVariant(opt, v);
|
||||
}, 0);
|
||||
}
|
||||
return sum + (quantities[opt.id] || 0) * effectiveUnitForOption(opt);
|
||||
return sum + (quantities[opt.id] || 0) * sessionsFor(opt.id, opt) * effectiveUnitForOption(opt);
|
||||
}, 0);
|
||||
}, [event, quantities]);
|
||||
}, [event, quantities, picks]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const isGuestEligible = !!(event.requiresAuth === false);
|
||||
const isLoggedIn = !!user || !!token;
|
||||
@@ -454,7 +496,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
return limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
|
||||
}, [event]);
|
||||
|
||||
const basket = useMemo(() => buildItems(), [quantities]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const basket = useMemo(() => buildItems(), [quantities, picks]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const bundleQuote = useBundleQuote({ eventId, basket, token, enabled: (event.bundles || []).length > 0 });
|
||||
// The server prices the basket (staff price, tiers, bundle deals) when quoting or checking
|
||||
// a code, so its total wins.
|
||||
@@ -472,15 +514,13 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
: 0;
|
||||
|
||||
function buildItems() {
|
||||
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
|
||||
const items: { eventOptionId: string; quantity: number; variantId?: string; sessionIds?: string[] }[] = [];
|
||||
Object.entries(quantities).forEach(([key, qty]) => {
|
||||
if (qty <= 0) return;
|
||||
if (key.includes(":")) {
|
||||
const [eventOptionId, variantId] = key.split(":");
|
||||
items.push({ eventOptionId, quantity: qty, variantId });
|
||||
} else {
|
||||
items.push({ eventOptionId: key, quantity: qty });
|
||||
}
|
||||
const [eventOptionId, variantId] = key.includes(":") ? key.split(":") : [key, undefined];
|
||||
const opt = (event.eventOptions || []).find(o => o.id === eventOptionId);
|
||||
const sessionIds = opt && choosing ? picks[key] || [] : undefined;
|
||||
items.push({ eventOptionId, quantity: qty, ...(variantId ? { variantId } : {}), ...(sessionIds ? { sessionIds } : {}) });
|
||||
});
|
||||
return items;
|
||||
}
|
||||
@@ -491,6 +531,11 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
setError("Select at least one ticket.");
|
||||
return;
|
||||
}
|
||||
const noSessions = items.find(i => i.sessionIds && i.sessionIds.length === 0);
|
||||
if (noSessions) {
|
||||
setError(`Choose the sessions for "${(event.eventOptions || []).find(o => o.id === noSessions.eventOptionId)?.name || "your ticket"}".`);
|
||||
return;
|
||||
}
|
||||
if (isYouthEvent) {
|
||||
const missing = consentDetailsMissing(event.eventOptions || [], qtyFor, attendeeEntries, event.startDate);
|
||||
if (missing) { setError(missing); return; }
|
||||
@@ -605,7 +650,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
|
||||
{offer && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-green-50 border-green-200 text-sm text-green-900">
|
||||
<strong>Your waitlist place:</strong> {offer.quantity} × {offer.optionName}{offer.variantName ? ` (${offer.variantName})` : ""} — held for you until {new Date(offer.expiresAt).toLocaleString()}.
|
||||
<strong>Your waitlist place:</strong> {offer.quantity} × {offer.lineName || `${offer.optionName}${offer.variantName ? ` (${offer.variantName})` : ""}`} — held for you until {new Date(offer.expiresAt).toLocaleString()}.
|
||||
</div>
|
||||
)}
|
||||
{offerError && (
|
||||
@@ -668,6 +713,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
quantities={quantities}
|
||||
onQtyChange={handleQtyChange}
|
||||
waitlist={event.waitlistEnabled ? { eventId, guestsAllowed: event.requiresAuth === false } : null}
|
||||
pick={choosing ? { event, picks, onPick: (key, ids) => setPicks(p => ({ ...p, [key]: ids })), perSession: pickFor(opt) } : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,9 @@ import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeFi
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { PublicBundle, Registration } from "@/types";
|
||||
import type { Event, PublicBundle, Registration } from "@/types";
|
||||
import { choosesSessions, isPickOption } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -209,25 +211,49 @@ export default function SelfServicePage() {
|
||||
[events, selectedEventId]
|
||||
);
|
||||
|
||||
// The events list doesn't carry bundle deals or places per session; the single-event
|
||||
// response does.
|
||||
const [kioskFull, setKioskFull] = useState<Event | null>(null);
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) { setKioskBundles([]); setKioskFull(null); return; }
|
||||
let cancelled = false;
|
||||
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
|
||||
.then(ev => { if (!cancelled) { setKioskBundles(ev?.bundles || []); setKioskFull(ev || null); } })
|
||||
.catch(() => { if (!cancelled) { setKioskBundles([]); setKioskFull(null); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedEventId, supervisorToken]);
|
||||
|
||||
// "Pick sessions" options: the sessions chosen per quantity key.
|
||||
const [picks, setPicks] = useState<Record<string, string[]>>({});
|
||||
useEffect(() => { setPicks({}); }, [selectedEventId]);
|
||||
const pickOpt = (optionId: string) => {
|
||||
const o = (kioskFull?.eventOptions || []).find(x => x.id === optionId);
|
||||
return kioskFull && o && isPickOption(kioskFull, o) ? o : null;
|
||||
};
|
||||
// On a "pick sessions" event every ticket has sessions to tick; only pickOpt ones are charged per session.
|
||||
const choiceOpt = (optionId: string) => {
|
||||
const o = (kioskFull?.eventOptions || []).find(x => x.id === optionId);
|
||||
return kioskFull && o && choosesSessions(kioskFull) ? o : null;
|
||||
};
|
||||
const times = (key: string) => (pickOpt(key.split("::")[0]) ? (picks[key] || []).length : 1);
|
||||
const picker = (key: string, optionId: string) => {
|
||||
const o = choiceOpt(optionId);
|
||||
if (!o || !kioskFull) return null;
|
||||
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
|
||||
const qty = quantities[key] || 0;
|
||||
return <SessionPicker event={kioskFull} option={o} quantity={Math.max(1, qty)} selected={qty > 0 ? picks[key] || [] : []}
|
||||
onChange={ids => { setPicks(p => ({ ...p, [key]: ids })); if (qty === 0 && ids.length > 0) setQuantities(q => ({ ...q, [key]: 1 })); }} />;
|
||||
};
|
||||
|
||||
const total = useMemo(() => {
|
||||
if (!selectedEvent) return 0;
|
||||
return (selectedEvent.eventOptions || []).reduce((sum, o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
|
||||
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * effectiveVariantUnit(o, v), 0);
|
||||
}
|
||||
return sum + (quantities[o.id] || 0) * effectiveUnit(o);
|
||||
return sum + (quantities[o.id] || 0) * times(o.id) * effectiveUnit(o);
|
||||
}, 0);
|
||||
}, [selectedEvent, quantities]);
|
||||
|
||||
// The events list doesn't carry bundle deals; the single-event response does.
|
||||
useEffect(() => {
|
||||
if (!selectedEventId) { setKioskBundles([]); return; }
|
||||
let cancelled = false;
|
||||
apiFetch<{ bundles?: PublicBundle[] }>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
|
||||
.then(ev => { if (!cancelled) setKioskBundles(ev?.bundles || []); })
|
||||
.catch(() => { if (!cancelled) setKioskBundles([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedEventId, supervisorToken]);
|
||||
}, [selectedEvent, quantities, picks, kioskFull]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const kioskBasket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
@@ -438,7 +464,7 @@ export default function SelfServicePage() {
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(choiceOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
|
||||
}),
|
||||
user,
|
||||
// If the visitor declined to create an account, don't push an unsolicited
|
||||
@@ -973,7 +999,8 @@ export default function SelfServicePage() {
|
||||
const baseVariantPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||
const key = `${opt.id}::${v.id}`;
|
||||
return (
|
||||
<div key={v.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div key={v.id}>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="text-gray-800">{v.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
@@ -999,6 +1026,8 @@ export default function SelfServicePage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{picker(key, opt.id) && <div className="px-4 pb-3">{picker(key, opt.id)}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1007,7 +1036,8 @@ export default function SelfServicePage() {
|
||||
}
|
||||
const unit = effectiveUnit(opt);
|
||||
return (
|
||||
<div key={opt.id} className="flex items-center justify-between border border-gray-200 rounded-xl px-4 py-3.5">
|
||||
<div key={opt.id} className="border border-gray-200 rounded-xl px-4 py-3.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{opt.name}</p>
|
||||
<p className="text-sm text-gray-500">{fmtCurrency(unit)}{unit !== opt.price && opt.price > 0 ? ` (was ${fmtCurrency(opt.price)})` : ""}</p>
|
||||
@@ -1030,6 +1060,8 @@ export default function SelfServicePage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{picker(opt.id, opt.id)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ type Claim = {
|
||||
eventStart: string;
|
||||
optionName: string;
|
||||
variantName: string | null;
|
||||
lineName?: string;
|
||||
quantity: number;
|
||||
requested: number;
|
||||
expiresAt: string;
|
||||
@@ -53,7 +54,7 @@ export default function WaitlistClaimPage() {
|
||||
{claim.status === "offered" ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-700">
|
||||
{claim.name ? `Hi ${claim.name}, ` : ""}{claim.quantity} × {claim.optionName}{claim.variantName ? ` (${claim.variantName})` : ""} {claim.quantity === 1 ? "is" : "are"} being held for you until <strong>{new Date(claim.expiresAt).toLocaleString()}</strong>.
|
||||
{claim.name ? `Hi ${claim.name}, ` : ""}{claim.quantity} × {claim.lineName || `${claim.optionName}${claim.variantName ? ` (${claim.variantName})` : ""}`} {claim.quantity === 1 ? "is" : "are"} being held for you until <strong>{new Date(claim.expiresAt).toLocaleString()}</strong>.
|
||||
{claim.quantity < claim.requested && ` You asked for ${claim.requested}; this is what came free.`}
|
||||
</p>
|
||||
<a href={`/register/${claim.eventId}?waitlist=${encodeURIComponent(token)}`} className="inline-block px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Timer, Play, RefreshCw } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type JobRun = { state: string; createdOn?: string; startedOn: string | null; completedOn: string | null; output: unknown };
|
||||
type JobInfo = { name: string; label: string; cron: string; enabled: boolean; envFlag: string | null; nextRun: string | null; recent: JobRun[] };
|
||||
type JobsResponse = { mode: "queue" | "timers" | "off"; timezone: string; jobs: JobInfo[] };
|
||||
|
||||
const STATE: Record<string, string> = {
|
||||
completed: "bg-green-50 text-green-800",
|
||||
failed: "bg-red-50 text-red-700",
|
||||
active: "bg-blue-50 text-blue-700",
|
||||
created: "bg-gray-100 text-gray-600",
|
||||
retry: "bg-amber-50 text-amber-800",
|
||||
cancelled: "bg-gray-100 text-gray-500",
|
||||
};
|
||||
|
||||
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
|
||||
|
||||
function describeCron(cron: string): string {
|
||||
const m = /^\*\/(\d+) \* \* \* \*$/.exec(cron);
|
||||
if (m) return `Every ${m[1]} minutes`;
|
||||
const d = /^(\d+) (\d+) \* \* \*$/.exec(cron);
|
||||
if (d) return `Daily at ${d[2].padStart(2, "0")}:${d[1].padStart(2, "0")}`;
|
||||
return cron;
|
||||
}
|
||||
|
||||
function outputText(run: JobRun): string {
|
||||
if (run.output === null || run.output === undefined) return "";
|
||||
if (typeof run.output === "object" && run.output && "message" in run.output && run.state === "failed") return String((run.output as { message: unknown }).message);
|
||||
try { return JSON.stringify(run.output); } catch { return ""; }
|
||||
}
|
||||
|
||||
// Settings → Background jobs: what runs when, the last runs of each job, and "Run now".
|
||||
export function JobsTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [data, setData] = useState<JobsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await apiFetch<JobsResponse>("/api/admin/jobs", { authToken: token }));
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to load background jobs" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, setMessage]);
|
||||
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
const runNow = async (job: JobInfo) => {
|
||||
if (!token) return;
|
||||
setRunning(job.name);
|
||||
try {
|
||||
const r = await apiFetch<{ queued: boolean }>(`/api/admin/jobs/${job.name}/run`, { method: "POST", authToken: token });
|
||||
setMessage({ type: "ok", text: r.queued ? `"${job.label}" queued — it starts within a few seconds.` : `"${job.label}" ran.` });
|
||||
setTimeout(load, r.queued ? 4000 : 0);
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Couldn't run the job" });
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2"><Timer className="w-5 h-5 text-brand-600" /> Background jobs</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Tasks the server runs on a schedule ({data?.timezone || "local"} time). Each runs once per schedule, even with more than one server process, and failed runs are retried.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={load} disabled={loading} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50 disabled:opacity-50 shrink-0">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && <div className={`p-3 border rounded text-sm ${message.type === "ok" ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`}>{message.text}</div>}
|
||||
{data?.mode === "timers" && (
|
||||
<div className="p-3 border rounded bg-amber-50 text-amber-900 text-sm">The job queue couldn't start, so jobs are running on simple timers in this server process (no retries, history only since the last restart). Check the server log for "[jobs]".</div>
|
||||
)}
|
||||
{data?.mode === "off" && (
|
||||
<div className="p-3 border rounded bg-gray-50 text-gray-700 text-sm">All background jobs are switched off (JOBS_ENABLED=false).</div>
|
||||
)}
|
||||
|
||||
<ul className="divide-y border rounded-lg">
|
||||
{(data?.jobs || []).map(job => {
|
||||
const last = job.recent[0];
|
||||
return (
|
||||
<li key={job.name} className="p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 mr-auto">
|
||||
<div className="font-medium text-sm text-gray-900">{job.label}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{job.enabled ? `${describeCron(job.cron)} · next ${when(job.nextRun)}` : `Off${job.envFlag ? ` (${job.envFlag}=false)` : ""}`}
|
||||
</div>
|
||||
</div>
|
||||
{last && (
|
||||
<button type="button" onClick={() => setOpen(open === job.name ? null : job.name)} className={`text-xs px-2 py-0.5 rounded ${STATE[last.state] || "bg-gray-100 text-gray-600"}`} title="Show recent runs">
|
||||
{last.state} · {when(last.completedOn || last.startedOn || last.createdOn || null)}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={running !== null || data?.mode === "off"} onClick={() => runNow(job)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50">
|
||||
<Play className="w-3.5 h-3.5" /> {running === job.name ? "Starting…" : "Run now"}
|
||||
</button>
|
||||
</div>
|
||||
{open === job.name && job.recent.length > 0 && (
|
||||
<table className="mt-2 w-full text-xs">
|
||||
<thead className="text-gray-500"><tr><th className="text-left font-medium py-1">Started</th><th className="text-left font-medium">Finished</th><th className="text-left font-medium">State</th><th className="text-left font-medium">Result</th></tr></thead>
|
||||
<tbody>
|
||||
{job.recent.map((r, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="py-1 pr-2 whitespace-nowrap">{when(r.startedOn)}</td>
|
||||
<td className="pr-2 whitespace-nowrap">{when(r.completedOn)}</td>
|
||||
<td className="pr-2"><span className={`px-1.5 py-0.5 rounded ${STATE[r.state] || ""}`}>{r.state}</span></td>
|
||||
<td className="font-mono text-[11px] text-gray-600 break-all">{outputText(r)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!loading && data && data.jobs.length === 0 && <li className="p-3 text-sm text-gray-500">No jobs.</li>}
|
||||
</ul>
|
||||
<p className="text-xs text-gray-400">Change a schedule with the env var JOB_SCHEDULE_<NAME> (cron, e.g. JOB_SCHEDULE_WAITLIST="*/5 * * * *"), or switch one off with its own flag.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
|
||||
eventId,
|
||||
eventOptionId: line.eventOptionId,
|
||||
...(line.variantId ? { variantId: line.variantId } : {}),
|
||||
...(line.sessionId ? { sessionId: line.sessionId } : {}),
|
||||
quantity: qty,
|
||||
...(!loggedIn ? { guestName: guest.name.trim(), guestEmail: guest.email.trim(), guestPhone: guest.phone.trim() || undefined } : {}),
|
||||
},
|
||||
@@ -124,5 +125,5 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
|
||||
}
|
||||
|
||||
function keyOf(l?: WaitlistLine) {
|
||||
return l ? `${l.eventOptionId}::${l.variantId || ""}` : "";
|
||||
return l ? `${l.eventOptionId}::${l.variantId || ""}::${l.sessionId || ""}` : "";
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ function PatternBuilder({ onGenerate }: { onGenerate: (rows: SessionDraft[], mod
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessions: KeyedSession[]; onChange: (s: KeyedSession[]) => void; defaultLocation?: string }) {
|
||||
export function SessionsEditor({ sessions, onChange, defaultLocation, showCapacity }: { sessions: KeyedSession[]; onChange: (s: KeyedSession[]) => void; defaultLocation?: string; showCapacity?: boolean }) {
|
||||
const [showPattern, setShowPattern] = useState(sessions.length === 0);
|
||||
const sorted = sortSessions(sessions);
|
||||
const activeCount = sessions.filter(s => !s.cancelled).length;
|
||||
@@ -137,7 +137,7 @@ export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessio
|
||||
const start = new Date(base.getTime() + (last ? 7 * 24 * 60 * 60 * 1000 : 0));
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
const fmt = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
onChange([...sessions, keyed({ label: "", startsAt: fmt(start), endsAt: fmt(new Date(start.getTime() + dur)), location: "", cancelled: false })]);
|
||||
onChange([...sessions, keyed({ label: "", startsAt: fmt(start), endsAt: fmt(new Date(start.getTime() + dur)), location: "", cancelled: false, capacity: last?.capacity || "" })]);
|
||||
};
|
||||
|
||||
let position = 0;
|
||||
@@ -180,7 +180,7 @@ export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessio
|
||||
)}
|
||||
</div>
|
||||
{!s.cancelled && (
|
||||
<div className="grid sm:grid-cols-3 gap-2 pl-8">
|
||||
<div className={`grid gap-2 pl-8 ${showCapacity ? "sm:grid-cols-4" : "sm:grid-cols-3"}`}>
|
||||
<label className="text-[11px] text-gray-500">Starts
|
||||
<input type="datetime-local" className="mt-0.5 w-full border rounded px-2 py-1 text-sm" value={s.startsAt} onChange={e => {
|
||||
const v = e.target.value;
|
||||
@@ -197,6 +197,11 @@ export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessio
|
||||
<label className="text-[11px] text-gray-500">Location (optional)
|
||||
<input className="mt-0.5 w-full border rounded px-2 py-1 text-sm" placeholder={defaultLocation || "Event location"} value={s.location} onChange={e => update(s.key, { location: e.target.value })} />
|
||||
</label>
|
||||
{showCapacity && (
|
||||
<label className="text-[11px] text-gray-500" title="Most people at this session (picked places plus whole-course tickets). Blank = no limit.">Capacity
|
||||
<input type="number" min={1} className="mt-0.5 w-full border rounded px-2 py-1 text-sm" placeholder="No limit" value={s.capacity} onChange={e => update(s.key, { capacity: e.target.value })} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{s.hasCheckIns && <p className="pl-8 text-[10px] text-gray-400">Has check-ins — it can be cancelled but not removed.</p>}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Event } from "@/types";
|
||||
import { bookableSessions, formatSessionWhen, sessionLabel } from "@/lib/sessions";
|
||||
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
|
||||
|
||||
// Tick boxes for the sessions of a "pick sessions" ticket. Full sessions (or ones with
|
||||
// fewer places than `quantity`) can't be ticked; with `waitlist`, they get a "Join
|
||||
// waitlist" button for that session instead.
|
||||
export function SessionPicker({ event, option, selected, onChange, quantity = 1, staff = false, unitPrice, waitlist }: {
|
||||
event: Pick<Event, "sessions">;
|
||||
option: { id: string; sessionAvailability?: Record<string, number> };
|
||||
selected: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
quantity?: number;
|
||||
staff?: boolean;
|
||||
unitPrice?: number;
|
||||
waitlist?: { eventId: string; guestsAllowed: boolean; variantId?: string | null; label: string } | null;
|
||||
}) {
|
||||
const sessions = bookableSessions(event, option, staff);
|
||||
if (sessions.length === 0) return <p className="text-xs text-gray-500 mt-2">No sessions left to book.</p>;
|
||||
const toggle = (id: string) => onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
|
||||
const allOpen = sessions.filter(s => s.left === null || s.left >= Math.max(1, quantity)).map(s => s.id);
|
||||
return (
|
||||
<div className="mt-2 space-y-1" role="group" aria-label="Choose sessions">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Choose your sessions{unitPrice !== undefined ? ` — R${unitPrice.toFixed(2)} each` : ""}</span>
|
||||
<button type="button" className="text-brand-700 hover:underline" onClick={() => onChange(selected.length === allOpen.length ? [] : allOpen)}>
|
||||
{selected.length === allOpen.length && allOpen.length > 0 ? "Clear" : "Select all"}
|
||||
</button>
|
||||
</div>
|
||||
{sessions.map(s => {
|
||||
const full = s.left !== null && s.left < Math.max(1, quantity);
|
||||
const name = sessionLabel(s, event.sessions);
|
||||
return (
|
||||
<div key={s.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${full ? "bg-gray-50 text-gray-400" : selected.includes(s.id) ? "border-brand-300 bg-brand-50" : "hover:bg-gray-50"}`}>
|
||||
<label className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<input type="checkbox" disabled={full && !selected.includes(s.id)} checked={selected.includes(s.id)} onChange={() => toggle(s.id)} />
|
||||
<span className="font-medium">{name}</span>
|
||||
<span className="text-gray-500">{formatSessionWhen(s)}</span>
|
||||
<span className="ml-auto text-xs">{s.left === null ? "" : s.left <= 0 ? "Full" : `${s.left} left`}</span>
|
||||
</label>
|
||||
{full && waitlist && !selected.includes(s.id) && (
|
||||
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
|
||||
lines={[{ eventOptionId: option.id, variantId: waitlist.variantId, sessionId: s.id, label: `${waitlist.label} — ${name}, ${formatSessionWhen(s)}` }]} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useState } from "react";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { entryLineName } from "@/lib/waitlist";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -14,6 +15,7 @@ type Entry = {
|
||||
event: { id: string; title: string; startDate: string };
|
||||
eventOption: { name: string };
|
||||
variant: { name: string } | null;
|
||||
session: { label: string | null; startsAt: string } | null;
|
||||
};
|
||||
|
||||
/** My Events: the waitlists I'm on, with Leave (and a note when a place is on offer). */
|
||||
@@ -46,7 +48,7 @@ export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m
|
||||
<li key={e.id} className="py-2 flex items-center justify-between gap-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">{e.event.title}</div>
|
||||
<div className="text-xs text-gray-500">{e.quantity} × {e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</div>
|
||||
<div className="text-xs text-gray-500">{e.quantity} × {entryLineName(e)}</div>
|
||||
{e.status === "offered" ? (
|
||||
<div className="text-xs text-green-700 mt-0.5">
|
||||
{e.offeredQuantity} place(s) held for you until {e.offerExpiresAt ? new Date(e.offerExpiresAt).toLocaleString() : ""} — use the link we sent you to claim.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for Site Settings (/dashboard/admin/settings), matching its six tabs. */
|
||||
/** Help content for Site Settings (/dashboard/admin/settings). */
|
||||
export const siteSettingsHelpContent: HelpContent = {
|
||||
title: "Site Settings help",
|
||||
subtitle: "Configure your organisation, branding, email, WhatsApp, and legal pages.",
|
||||
@@ -96,5 +96,24 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "jobs",
|
||||
label: "Background jobs",
|
||||
icon: Timer,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>Tasks the server runs on a schedule: the daily summary email, waitlist offers, feedback forms, session reminders and certificates, consent reminders, analytics, the nightly backup and temp-file cleanup.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Timer} title="Schedule and history" tone="brand">
|
||||
Each job shows when it runs and when it runs next. Click a job's last result to see its last 10 runs — when they started and finished, and what they did (or the error). A failed run is retried twice.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Play} title="Run now" tone="green">
|
||||
Runs a job straight away, e.g. to send a feedback form without waiting. It's recorded in the audit log.
|
||||
</GuideItem>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Jobs run once per schedule even when more than one server process is running. Schedules and on/off switches are set by the server's environment variables.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
|
||||
<p>Selecting a registration automatically jumps to Payment mode if it's unpaid, or Check-in mode if it's already fully paid.</p>
|
||||
<p>When editing an existing registration's items, quantities can't be reduced below what's already been printed or paid for.</p>
|
||||
<p>The item picker has a "Have a code?" field for discount codes. On an existing registration the code is applied when you save.</p>
|
||||
<p>On events where people pick sessions, tick the sessions under a per-session ticket. For a new attendee, a place in today's session is selected for you when there's room. Sessions already booked on a registration can't be changed when editing it — book extra sessions as a new registration.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -37,6 +37,9 @@ export const supervisorEventsHelpContent: HelpContent = {
|
||||
<GuideItem icon={CalendarDays} title="Multiple sessions" tone="brand">
|
||||
Choose "Multiple sessions" for one event on several dates — e.g. every Wednesday 18:30–20:30 for 5 weeks. Use the repeat pattern to create the dates, then edit any of them, add a label ("Week 1 — …"), a different location, or an extra date. People register and pay once and get one ticket for every session. Cancel a session (e.g. a public holiday) rather than deleting it once people have been checked in. Registration closes when the first session starts unless you turn on late joining.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CalendarDays} title="Pick sessions" tone="violet">
|
||||
For day passes or a drop-in class, set "How people book" to "Pick sessions", give sessions an optional capacity, and in Items & Pricing set a ticket to "Pick sessions (price per session)". People tick the sessions they want; each one is its own ticket. A ticket left on "All sessions" also shows the sessions to tick, but it's one ticket at one price — it then only scans on the sessions ticked.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ListChecks} title="Items & pricing" tone="green">
|
||||
Ticket options and stock, variants of each option, and early-bird pricing tiers. An optional staff price is charged automatically to Staff and above when it's lower than the normal price.
|
||||
</GuideItem>
|
||||
|
||||
@@ -19,13 +19,13 @@ export const supervisorWaitlistHelpContent: HelpContent = {
|
||||
In the event wizard's Visibility step, tick "Waitlist for sold-out tickets", set how long offered places are held (24 hours by default), and whether someone can be offered fewer places than they asked for.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ListChecks} title="Joining" tone="green">
|
||||
When a ticket is sold out, the event and registration pages show "Join waitlist". People pick the ticket and how many, and can leave again from My Events.
|
||||
When a ticket is sold out, the event and registration pages show "Join waitlist". People pick the ticket and how many, and can leave again from My Events. On events where people pick sessions, the waitlist is per session: a full session shows "Join waitlist" in the session list, and the Ticket column shows which session someone is waiting for.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Send} title="Offers" tone="violet">
|
||||
When places free up — a cancellation, a smaller booking, or a higher stock limit — the next person in line is emailed or WhatsApped a claim link. The places are held for them, so nobody else can buy them in the meantime.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Clock} title="Expiry" tone="amber">
|
||||
If the offer isn't claimed in time, it expires and the places go to the next person. Claiming goes through normal registration and payment, with early-bird and coupon rules as usual.
|
||||
If the offer isn't claimed in time, it expires and the places go to the next person. An offer for a session never runs past the session's start. Claiming goes through normal registration and payment, with early-bird and coupon rules as usual.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ export type SessionDraft = {
|
||||
endsAt: string;
|
||||
location: string;
|
||||
cancelled: boolean;
|
||||
capacity: string; // "pick sessions" events; blank = no limit
|
||||
hasCheckIns?: boolean;
|
||||
};
|
||||
|
||||
@@ -54,7 +55,7 @@ export function generateSessions(p: SessionPattern): SessionDraft[] {
|
||||
const start = atTime(day, p.startTime);
|
||||
let end = atTime(day, p.endTime);
|
||||
if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
|
||||
out.push({ label: "", startsAt: localDT(start), endsAt: localDT(end), location: "", cancelled: false });
|
||||
out.push({ label: "", startsAt: localDT(start), endsAt: localDT(end), location: "", cancelled: false, capacity: "" });
|
||||
};
|
||||
|
||||
if (p.frequency === "daily") {
|
||||
@@ -83,7 +84,7 @@ export function generateSessions(p: SessionPattern): SessionDraft[] {
|
||||
}
|
||||
|
||||
export function toSessionDraft(s: EventSession & { hasCheckIns?: boolean }): SessionDraft {
|
||||
return { id: s.id, label: s.label || "", startsAt: localDT(new Date(s.startsAt)), endsAt: localDT(new Date(s.endsAt)), location: s.location || "", cancelled: !!(s.cancelled || s.cancelledAt), hasCheckIns: s.hasCheckIns };
|
||||
return { id: s.id, label: s.label || "", startsAt: localDT(new Date(s.startsAt)), endsAt: localDT(new Date(s.endsAt)), location: s.location || "", cancelled: !!(s.cancelled || s.cancelledAt), capacity: s.capacity ? String(s.capacity) : "", hasCheckIns: s.hasCheckIns };
|
||||
}
|
||||
|
||||
/** Sessions that aren't cancelled, earliest first. */
|
||||
@@ -163,3 +164,39 @@ export function currentSessionLabel(event: Pick<Event, "scheduleType" | "session
|
||||
const s = activeSessions(event.sessions).find(x => new Date(x.startsAt).toDateString() === today);
|
||||
return s ? sessionLabel(s, event.sessions) : null;
|
||||
}
|
||||
|
||||
/** The session of a multi-day event open for check-in today (browser's local day), or null. */
|
||||
export function todaysSession(event: Pick<Event, "scheduleType" | "sessions"> | null | undefined): EventSession | null {
|
||||
if (!event || event.scheduleType !== "sessions") return null;
|
||||
const today = new Date().toDateString();
|
||||
const now = Date.now();
|
||||
const list = activeSessions(event.sessions).filter(x => new Date(x.startsAt).toDateString() === today);
|
||||
return list.find(s => now <= new Date(s.endsAt).getTime()) || list[list.length - 1] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether people tick sessions when booking this event ("pick sessions" attendance).
|
||||
* Every ticket shows the sessions; only per-session tickets are charged per session.
|
||||
*/
|
||||
export function choosesSessions(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined): boolean {
|
||||
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick";
|
||||
}
|
||||
|
||||
/** Whether an option is bought per session (only in "pick sessions" events). */
|
||||
export function isPickOption(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined, option: { sessionMode?: "all" | "pick" } | null | undefined): boolean {
|
||||
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick" && option?.sessionMode === "pick";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions a pick option can still be booked for, with places left (null = no limit).
|
||||
* Public: before the session starts; staff: until it ends.
|
||||
*/
|
||||
export function bookableSessions(event: Pick<Event, "sessions">, option: { sessionAvailability?: Record<string, number> }, staff = false): (EventSession & { left: number | null })[] {
|
||||
const now = Date.now();
|
||||
return activeSessions(event.sessions)
|
||||
.filter(s => new Date(staff ? s.endsAt : s.startsAt).getTime() > now)
|
||||
.map(s => {
|
||||
const limits = [s.placesLeft, option.sessionAvailability?.[s.id]].filter((n): n is number => typeof n === "number");
|
||||
return { ...s, left: limits.length ? Math.min(...limits) : null };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Ticket as TicketRecord } from "@/types";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { scheduleSummary } from "@/lib/sessions";
|
||||
import { formatSessionWhen, scheduleSummary } from "@/lib/sessions";
|
||||
|
||||
// One ticket design for every printed ticket (user dashboard, staff event tickets, at the
|
||||
// door). The emailed/WhatsApp PDF in backend/src/controllers/ticketController.js mirrors it.
|
||||
@@ -18,8 +18,12 @@ export function ticketTypeLabel(t: TicketRecord): string {
|
||||
/** A ticket card. `buyerName` adds a "Purchased by" line. */
|
||||
export function ticketCardHtml(t: TicketRecord, buyerName?: string | null): string {
|
||||
const eventTitle = t.event?.title || t.eventId || "Event";
|
||||
// Multi-day events: "5 sessions · Wed 4 Nov – Wed 2 Dec" — one ticket for every session.
|
||||
const dateStr = (t.event && scheduleSummary(t.event)) || (t.event?.startDate ? formatDate(t.event.startDate) : "");
|
||||
// Multi-day events: "5 sessions · Wed 4 Nov – Wed 2 Dec" — one ticket for every session;
|
||||
// a picked-session ticket shows its own session.
|
||||
const session = t.registrationOption?.session;
|
||||
const dateStr = session
|
||||
? `${session.label || "Session"} · ${formatSessionWhen(session)}`
|
||||
: (t.event && scheduleSummary(t.event)) || (t.event?.startDate ? formatDate(t.event.startDate) : "");
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=${encodeURIComponent(t.qrCode || t.id)}`;
|
||||
return `
|
||||
<div class="ticket">
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
// Pure helpers shared by server and client components. Must not be "use client",
|
||||
// or server pages calling these get a client reference instead of the function.
|
||||
|
||||
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
|
||||
// sessionId: one session of a "pick sessions" option (those are waited for per session).
|
||||
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; sessionId?: string | null; label: string };
|
||||
|
||||
/** "Drop-in (Adult)" or "Drop-in — Class 2, Wed 4 Nov" for a waitlist entry. */
|
||||
export function entryLineName(e: { eventOption: { name: string }; variant?: { name: string } | null; session?: { label?: string | null; startsAt: string } | null }): string {
|
||||
const base = `${e.eventOption.name}${e.variant ? ` (${e.variant.name})` : ""}`;
|
||||
if (!e.session) return base;
|
||||
const when = new Date(e.session.startsAt).toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" });
|
||||
return `${base} — ${e.session.label ? `${e.session.label}, ` : ""}${when}`;
|
||||
}
|
||||
|
||||
/** The sold-out lines of an event, for the waitlist button. */
|
||||
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {
|
||||
|
||||
@@ -73,6 +73,10 @@ export type EventOption = {
|
||||
earlyBirdTiers?: EarlyBirdTier[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
// "Pick sessions" events: `pick` options are bought per session (price per session).
|
||||
sessionMode?: "all" | "pick";
|
||||
// Pick options with a stock limit: places left per session id.
|
||||
sessionAvailability?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type FormFieldType =
|
||||
@@ -158,6 +162,8 @@ export type Event = {
|
||||
// When public registration closes: the deadline, the first session (or session N with
|
||||
// late joining), or the end date — whichever applies.
|
||||
registrationClosesAt?: string;
|
||||
// "Pick sessions" events: when whole-course tickets stop selling.
|
||||
wholeCourseClosesAt?: string | null;
|
||||
lateJoinEnabled?: boolean;
|
||||
lateJoinUntilSessionId?: string | null;
|
||||
latePricing?: "full" | "prorata" | "fixed";
|
||||
@@ -182,6 +188,11 @@ export type EventSession = {
|
||||
cancelledAt?: string | null;
|
||||
cancelled?: boolean;
|
||||
order?: number;
|
||||
// "Pick sessions" events: optional limit on people, and (single-event responses) how
|
||||
// many are booked and how many places are left (null = no limit).
|
||||
capacity?: number | null;
|
||||
booked?: number;
|
||||
placesLeft?: number | null;
|
||||
};
|
||||
|
||||
export type LateJoinInfo = {
|
||||
@@ -208,6 +219,9 @@ export type CertificateConfig = {
|
||||
export type AttendanceColumn = { id: string; label: string; startsAt: string; endsAt: string; cancelled: boolean };
|
||||
export type AttendanceRow = {
|
||||
ticketId: string;
|
||||
// Picked-session bookings: the sessions booked (null = all) and the ticket for each.
|
||||
bookedSessions?: string[] | null;
|
||||
sessionTickets?: Record<string, string> | null;
|
||||
name: string;
|
||||
bookedBy: string;
|
||||
email: string;
|
||||
@@ -458,6 +472,9 @@ export type RegistrationOption = {
|
||||
appliedTierId?: string | null;
|
||||
eventOption?: EventOption;
|
||||
variant?: OptionVariant | null;
|
||||
// Picked-session lines: the one session this line (and its ticket) is for.
|
||||
sessionId?: string | null;
|
||||
session?: EventSession | null;
|
||||
// One entry per price the line was bought at (e.g. some before an early-bird tier expired).
|
||||
tranches?: { id: string; quantity: number; priceSnapshot: number; appliedTierId?: string | null }[];
|
||||
tickets?: { id: string; quantity?: number }[];
|
||||
@@ -528,7 +545,7 @@ export type Ticket = {
|
||||
transferRequests?: { id: string; toName: string; toEmail?: string | null; toPhone?: string | null; createdAt?: string }[];
|
||||
event?: Event;
|
||||
registrationOption?: RegistrationOption;
|
||||
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
|
||||
usages?: { id: string; scannedAt: string; quantityRedeemed?: number; sessionId?: string | null }[];
|
||||
};
|
||||
|
||||
export type Payment = {
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Generated
+48
-2
@@ -1,17 +1,34 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.33.0",
|
||||
"version": "1.35.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.33.0",
|
||||
"version": "1.35.1",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
|
||||
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -175,6 +192,35 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
|
||||
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
|
||||
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.33.0",
|
||||
"version": "1.35.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
@@ -9,13 +9,16 @@
|
||||
"start:frontend": "cd frontend && npm run start",
|
||||
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate"
|
||||
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate",
|
||||
"test:e2e": "playwright test",
|
||||
"test": "npm --prefix backend test"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
"concurrently": "^9.2.1"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { e2eDatabaseUrl } = require("./e2e/support/db-url");
|
||||
|
||||
// End-to-end tests: their own backend (port 5100) and frontend (port 3100) against a
|
||||
// separate database that is recreated and seeded on every run (e2e/support/prepare-db.js).
|
||||
// Run with `npm run test:e2e`. In CI (CI=true) the frontend is built and started instead
|
||||
// of run in dev mode.
|
||||
const API_PORT = 5100;
|
||||
const WEB_PORT = 3100;
|
||||
const ci = !!process.env.CI;
|
||||
const databaseUrl = e2eDatabaseUrl();
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testMatch: "**/*.spec.ts",
|
||||
// One shared database, so tests run one after another.
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
retries: ci ? 1 : 0,
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 20_000 },
|
||||
reporter: ci ? [["list"], ["html", { open: "never", outputFolder: "e2e/report" }]] : "list",
|
||||
outputDir: "e2e/test-results",
|
||||
use: {
|
||||
baseURL: `http://localhost:${WEB_PORT}`,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
// Dev-mode pages compile on first visit.
|
||||
navigationTimeout: 90_000,
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: [
|
||||
{
|
||||
command: "node ../e2e/support/prepare-db.js && node src/index.js",
|
||||
cwd: "backend",
|
||||
url: `http://localhost:${API_PORT}/api/settings/needs-setup`,
|
||||
timeout: 180_000,
|
||||
reuseExistingServer: !ci,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
PORT: String(API_PORT),
|
||||
DATABASE_URL: databaseUrl,
|
||||
E2E_DATABASE_URL: databaseUrl,
|
||||
FRONTEND_URL: `http://localhost:${WEB_PORT}`,
|
||||
APP_BASE_URL: `http://localhost:${WEB_PORT}`,
|
||||
JOBS_ENABLED: "false",
|
||||
SCHEDULED_EMAILS_ENABLED: "false",
|
||||
// The kiosk test signs a supervisor in with a password (no 2FA code by email).
|
||||
TWO_FACTOR_REQUIRED_ROLES: "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
command: ci ? `npm run build && npx next start -p ${WEB_PORT}` : `npx next dev -p ${WEB_PORT}`,
|
||||
cwd: "frontend",
|
||||
url: `http://localhost:${WEB_PORT}`,
|
||||
timeout: 600_000,
|
||||
reuseExistingServer: !ci,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: `http://localhost:${API_PORT}`,
|
||||
API_URL: `http://localhost:${API_PORT}`,
|
||||
NEXT_DIST_DIR: ci ? ".next" : ".next-e2e",
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Updates this install to a tagged release: back up the database, check out the tag,
|
||||
# install, migrate, build, and reload PM2. If anything fails before the reload, the
|
||||
# previous version keeps running and the checkout goes back to it.
|
||||
#
|
||||
# Usage (on the server, from the repo folder): bash scripts/deploy.sh v1.34.0
|
||||
# CI runs this over SSH when a v* tag is pushed (see .gitea/workflows/ci.yml).
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:?Usage: scripts/deploy.sh <tag>}"
|
||||
cd "$(dirname "$0")/.."
|
||||
PREVIOUS="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
|
||||
|
||||
echo "==> Deploying $TAG (currently $PREVIOUS)"
|
||||
|
||||
echo "==> Backing up the database"
|
||||
(cd backend && node -e "require('dotenv').config(); require('./src/utils/backupUtils').runBackup().then(r => console.log(' ', r.filename)).catch(e => { console.error(' Backup failed:', e.message); process.exit(1); })")
|
||||
|
||||
rollback() {
|
||||
echo "!!> Deploy failed — going back to $PREVIOUS (the running version was not touched)"
|
||||
git checkout -q "$PREVIOUS"
|
||||
(cd backend && npm ci --silent) || true
|
||||
(cd frontend && npm ci --silent && npm run build --silent) || true
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
git fetch --tags --quiet origin
|
||||
git checkout -q "$TAG"
|
||||
|
||||
echo "==> Backend: install and migrate"
|
||||
(cd backend && npm ci && npm run prisma:deploy)
|
||||
|
||||
echo "==> Frontend: install and build"
|
||||
(cd frontend && npm ci && npm run build)
|
||||
|
||||
trap - ERR
|
||||
echo "==> Reloading PM2"
|
||||
pm2 reload ecosystem.config.js --update-env
|
||||
|
||||
echo "==> Deployed $TAG"
|
||||
Reference in New Issue
Block a user