Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 2bcc9ac651 Setting events up on API v2, pg-boss 11 (1.51.7)
- v2: /events/manage, create, PATCH, deactivate, copy-from, notify-recipients.
- src/services/events.ts (from eventController.js, with the schedule helpers); v1 uses it.
- fetchManagedEvents() for every picker; the event wizard writes through v2.
- pg-boss 11 in its own schema (pgboss_v11); old pgboss schema left in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 11:57:08 +02:00
joshua b2906011fd Merge branch 'feat/palette-excelts' 2026-10-29 11:16:38 +02:00
joshuaandClaude Opus 5.5 70850df4f6 Complete command palette, ExcelTS 10, roadmap: v2 for webhooks and sent links (1.51.6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 11:14:19 +02:00
joshua cbebcdbd48 Merge branch 'chore/next16' 2026-10-29 11:00:31 +02:00
joshuaandClaude Opus 5.5 849998acb3 Next.js 16, React 19.3, ESLint 10 (1.51.5)
- Next 16 (Turbopack dev and build), React 19.3; tsconfig jsx react-jsx; images remotePatterns.
- ESLint 10 with eslint-config-next 16 flat configs; npm run lint = eslint src. React
  Compiler-era react-hooks rules set to warn (181 existing places) until screens move to v2.
- Removed the empty pass-through middleware.ts.
- e2e: wait for the palette to be ready before Ctrl+K on the staff dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 11:00:30 +02:00
joshua c5fe17a1ef Merge branch 'chore/dependencies' 2026-10-29 10:19:43 +02:00
joshuaandClaude Opus 5.5 6164fdcb3b Security and dependency updates, Node 24 for new installs (1.51.4)
- npm audit fixes: next 15.5.28 and @sentry/nextjs 10.78 (critical), zod 4.6.7, multer 2.5.0
  (high), nodemailer 10.3.0, fast-uri, ip-address (moderate). Minimum versions raised.
- cron-parser 5 (v4 unmaintained).
- install.sh installs Node 24 LTS; deploy.sh warns on Node < 22; DEPLOYMENT.md "Upgrading Node".
- Roadmap: every endpoint moves to v2 before 2.0 (1.51.5 - 1.51.11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 10:19:43 +02:00
joshua 1510c2a057 Merge branch 'chore/roadmap-v1-decision' 2026-10-29 09:49:54 +02:00
joshuaandClaude Opus 5.5 fa4191d18c Roadmap: 2.0 retires only the v1 endpoints that have a v2 replacement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 09:49:54 +02:00
joshua e0dae5647f Merge branch 'feat/api-v2-express5' 2026-10-29 09:46:24 +02:00
joshuaandClaude Opus 5.5 4109cb4258 Express 5, scanner on API v2, v1 deprecation headers (1.51.3)
- Express 5 (qs query parser and req.body = {} kept as in Express 4).
- Scanner and at-the-door check-in use /api/v2/checkin; look-up gives attendee, bookedBy,
  ticketOptionId.
- Shared middleware errors on /api/v2 rewritten to { error: { code, message, reason } };
  apiFetch treats reason like v1's code.
- Deprecation + Link: rel="successor-version" on v1 endpoints that have a v2 replacement.
- Roadmap: open question on how much of v1 to retire in 2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 09:46:24 +02:00
64 changed files with 5063 additions and 3046 deletions
+113
View File
@@ -7,6 +7,119 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.51.7] - 2026-10-29
### Added
- **API v2 — setting events up:** `GET /events/manage` (every event staff may see, hidden ones too;
`past`, `inactive`, `closed`; campus-scoped), `POST /events`, `PATCH /events/{id}` (only what's sent
changes; `sessionChanges` when sessions were sent), `DELETE /events/{id}` (admins; deactivates),
`POST /events/{id}/copy-from`, and `GET`/`PUT /events/{id}/notify-recipients`. Event records are
typed for their core fields and pass the rest through as stored (`z.looseObject`).
- **`src/services/events.ts`:** create, update, deactivate, the staff list, notify recipients and
copy-from moved out of `eventController.js` (2,217 → 1,717 lines), with the schedule helpers
(`withSchedule`, `scheduleData`, `saveSchedule`). v1 uses it too. The pre-migration fallbacks
(retrying without `registrationDeadline`, `goLiveAt`…) are gone — every install has those columns.
Creating an event now also returns its ticket options.
- Frontend: `fetchManagedEvents()` (`lib/api.ts`) — every event picker, the kiosk, reports and the
set-up screens load events through v2; the event wizard creates, saves, copies and sets
notification recipients through v2. v1 deprecation links for these endpoints.
### Changed
- **pg-boss 11** (servers are on Node 24). It can't upgrade pg-boss 10's tables, so the job queue
uses a new schema, `pgboss_v11`; schedules are recreated at start, and the admin page's
"recent runs" start again from this release. The old `pgboss` schema is left as it was (it can
be dropped: `DROP SCHEMA pgboss CASCADE;`). pg-boss 11 brings cron-parser 5, so the last
cron-parser 4 deprecation is gone.
- Roadmap: the rest of the events area (event details for staff, ticket options and variants,
attachments, sections, costs, series, season passes, bundles, coupons, campuses, calendar file,
alias) is 1.51.8; the later areas move up one (to 1.51.14).
## [1.51.6] - 2026-10-29
### Fixed
- **Command palette (Ctrl+K) was missing things.** It listed 9 of the 19 reports — now every
report (built from the report catalogue, so new ones appear by themselves), plus Dashboard,
Reports, Ticket options and My groups, and your own pages (My events & tickets, My payments,
My privacy, My kids, My serving, My attendee forms, Make a donation). Attendee forms now opens
for staff and supervisors too (the page allows them; the palette had it as admin-only). An e2e
test fails when a dashboard page is neither in the palette nor deliberately left out.
### Changed
- **Excel exports use ExcelTS** (`@cj-tech-master/excelts` 10) instead of exceljs, which is
unmaintained: same API, no dependencies — the backend has 87 fewer packages and none of
exceljs's deprecated ones (fstream, glob 7, inflight, lodash.isequal, rimraf 2). New
`e2e/excel.spec.ts` opens a real report and attendance export. ExcelTS itself now says it's no
longer developed (its successor, documonster, is pre-1.0); existing versions keep working.
- The backend needs **Node 22 or newer** (`engines`); the servers run Node 24 LTS.
- Roadmap: incoming webhooks and links in sent messages get v2 URLs too (1.51.13), with the v1 paths
still answering; the area releases are now 1.51.7 – 1.51.13.
## [1.51.5] - 2026-10-29
### Changed
- **Next.js 16** (from 15.5) and **React 19.3**. Builds and dev use Turbopack (Next 16's default);
`tsconfig.json` `jsx` is `react-jsx` (required by Next 16); `images.domains` → `remotePatterns`.
- **ESLint 10** (ESLint 9 is out of support) with Next 16's own flat configs
(`eslint-config-next/core-web-vitals` and `/typescript`, no more FlatCompat). `npm run lint` is
`eslint src` (`next lint` was removed). Next 16's React Compiler-era rules
(`react-hooks/set-state-in-effect`, `purity`, `immutability`, `static-components`, `refs`) are
warnings for now — 181 places, mostly loading data in `useEffect`.
- Removed the empty `middleware.ts` (a pass-through that did nothing; Next 16 renames middleware to
"proxy"), which also drops its 117 kB bundle.
## [1.51.4] - 2026-10-29
### Security
- **Next.js 15.5.28** (critical: remote code execution through the image optimiser with SVGs, and a
Server Actions denial of service), **@sentry/nextjs 10.78** (critical: middleware bypass),
**zod 4.6.7** (high: memory exhaustion on oversized collections; backend and frontend),
**multer 2.5.0** (high: denial of service from a pending file filter), **nodemailer 10.3.0**
(moderate: three address-parsing flaws that could add recipients), fast-uri 3.1.9 and
ip-address 10.7.5 (moderate). Minimum versions raised in package.json so a fresh install can't
resolve an older one. `npm audit`: frontend and root clean; the backend's 6 remaining reports are
low and dev-only (jest → deepmerge), with no real fix (npm suggests downgrading Jest to 25).
### Changed
- **cron-parser 5** (v4 is unmaintained): `CronExpressionParser.parse` in `src/jobs/index.js`.
- **Node 24 LTS** for new installs (`scripts/install.sh`); Node 20 reached end of life in April 2026.
`deploy.sh` warns on servers older than Node 22; DEPLOYMENT.md has "Upgrading Node".
- Roadmap: every endpoint moves to v2 before 2.0 (1.51.5 – 1.51.11).
### Known
- Still deprecated, with reasons: exceljs (unmaintained; brings fstream, glob 7, inflight,
lodash.isequal, rimraf 2) — replacing it with ExcelTS waits for approval; ESLint 9 (out of
support) — ESLint 10 needs Next.js 16 (1.51.5); @opentelemetry/sdk-trace-base — pulled in by the
latest @sentry/nextjs, for Sentry to update; glob 10 — inside Jest (dev only).
## [1.51.3] - 2026-10-29
### Changed
- **Express 5** (from 4). Kept Express 4 behaviour where it changed: the `qs` ("extended") query
parser, and `req.body` as `{}` when a request has no JSON body. `@types/express` 5.
- **The ticket scanner and at-the-door check-in use API v2** (`/api/v2/checkin/{qrCode}`). The v2
look-up now gives `attendee`, `bookedBy` and `ticketOptionId` (for section checks).
- **Shared middleware answers in the v2 shape on /api/v2:** rate limits, the impersonation and
licence read-only guards, the auth checks and campus refusals write v1's `{ message, code }`;
on v2 that becomes `{ error: { code, message, reason } }` (`reason` = the specific old code, e.g.
`TWO_FACTOR_REQUIRED`, `LICENCE_READ_ONLY`). Read-only answers have the v2 code `READ_ONLY` (403).
The frontend's `apiFetch` treats `reason` like v1's `code`, so the 2FA and impersonation
handling works on v2 calls too.
### Added
- **v1 deprecation headers:** v1 endpoints that have a v2 replacement answer with
`Deprecation: @<date>` (RFC 9745) and `Link: <…>; rel="successor-version"` (`src/v2/deprecation.ts`).
The rest of v1 has no replacement yet, so no header.
## [1.51.2] - 2026-10-29
### Added
+19 -1
View File
@@ -56,7 +56,7 @@ curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scr
sudo bash install.sh --domain events.yourchurch.org --no-ssl --version v1.41.2
```
It installs Node 20, PostgreSQL, PM2 and nginx, creates the database, writes the config, builds
It installs Node 24 LTS, PostgreSQL, PM2 and nginx, creates the database, writes the config, builds
and starts the app (in `/opt/hope-events`, run by the `hopeevents` user). Use `--name`, `--dir`,
`--web-port` and `--api-port` if other apps already use those names or ports; adjust the ports
below to match.
@@ -311,6 +311,24 @@ and goes back to the previous version if anything fails first. `backend/.env`,
`frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so
updates keep your changes from this guide.
### Upgrading Node
Node 20 stopped getting security fixes in April 2026; Cross Events runs on Node 24 LTS (supported
until April 2028). `deploy.sh` warns when a server is still on an older Node. To upgrade:
```bash
node -v # what you have now
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo bash -
sudo apt-get install -y nodejs # replaces the old Node
sudo npm install -g pm2 # PM2 for the new Node
cd /opt/hope-events
sudo -u hopeevents -H pm2 update # restart PM2's daemon on the new Node
sudo -u hopeevents -H bash scripts/deploy.sh v1.51.4 # reinstall packages and rebuild for Node 24
```
Use the version you're on (or a newer one) in the last line — the deploy reinstalls every package
and rebuilds, which a Node upgrade needs.
## Troubleshooting
| Symptom | Likely cause |
+5 -2
View File
@@ -892,10 +892,13 @@ can't start, the same jobs run on in-process timers.
- Backend status page (`/`) and built-in API docs page (`/docs`); **API v2** (`/api/v2`, 1.51.0)
with one error shape, paged lists and an OpenAPI 3.1 reference at `/docs/v2` (admins). Public
events, my account and registrations, registering, cancelling and paying online (1.51.1),
signing up, logging in (with the 2FA step), changing my details and checking tickets in (1.51.2);
the frontend's v2 calls are typed from the OpenAPI document.
signing up, logging in (with the 2FA step), changing my details and checking tickets in (1.51.2;
the scanner and at-the-door use it from 1.51.3); the frontend's v2 calls are typed from the
OpenAPI document. v1 endpoints with a v2 replacement send `Deprecation` and a `successor-version`
link (1.51.3).
- **TypeScript backend** (1.51.0): `.ts` files are checked strictly, `.js` compiles as it is;
deploys build `dist/`, which `src/index.js` runs.
- **Command palette** (Ctrl+K): every page, settings tab, report and quick action for your role.
- **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
+1 -1
View File
@@ -107,7 +107,7 @@ curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scr
sudo bash install.sh --domain events.yourchurch.org --email you@yourchurch.org [--version v1.41.0]
```
It installs Node 20, PostgreSQL (with its own database and a random password), PM2, nginx (the
It installs Node 24 LTS, PostgreSQL (with its own database and a random password), PM2, nginx (the
API, uploads, `/health` and `/docs` to the backend, everything else to the site) and an HTTPS
certificate, writes `backend/.env` and `frontend/.env.production`, migrates, builds and starts the
app. Then open `https://<domain>/setup`: create the admin account and enter the **licence key**
+30
View File
@@ -12,6 +12,36 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.51.7
- **More groundwork for 2.0.** Creating and editing events, and every list of events staff choose
from, now run on the new version of the server's connection to the app. They work as before.
- The background job runner was updated. Its list of recent runs (Site settings → Background jobs)
starts afresh with this version.
## 1.51.6
- **Find anything with Ctrl+K.** The search box (Ctrl+K) now lists every report, and pages that
were missing: the dashboard, all reports, ticket options, your groups, and your own events,
payments, privacy, kids, serving, forms and donations.
- Excel downloads use a newer, maintained library. They look the same.
## 1.51.5
- **Updated framework.** The site now runs on the latest version of its web framework. Nothing
changes in how it works.
## 1.51.4 — security
- **Security updates** to the parts the site is built on, including a critical fix in the web
framework. Recommended for everyone.
## 1.51.3
- **More groundwork for 2.0.** The ticket scanner and check-in at the door now run on the new version
of the server's connection to the app, and the server's web framework has been updated. They work
just as before.
## 1.51.2
- **More groundwork for 2.0.** Logging in, signing up and changing your details now run on the
+2 -1
View File
@@ -6,7 +6,8 @@ Full interactive docs (with request/response examples) are available at `GET /do
> (admin token), the document at `GET /api/v2/openapi.json` (admin). It is generated from the route
> declarations, so it is not repeated here. v2 responses are `{ data }` (lists add
> `meta: { page, limit, total, pages }`, `?page=&limit=` up to 100) and errors
> `{ error: { code, message, fields? } }`. This file documents v1 (`/api/…`), which stays until 2.0. The frontend calls v2 through the typed
> `{ error: { code, message, fields? } }`. This file documents v1 (`/api/…`). v1 endpoints that have a v2 replacement send `Deprecation` and
> `Link: <v2 path>; rel="successor-version"` headers (1.51.3). The frontend calls v2 through the typed
> `apiV2()` helper (`frontend/src/lib/apiV2.ts`, types regenerated with `npm run api:types`).
All authenticated requests require:
+435 -1443
View File
File diff suppressed because it is too large Load Diff
+12 -9
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.2",
"version": "1.51.7",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -20,34 +20,34 @@
"openapi": "tsx scripts/openapi.ts"
},
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"@sentry/node": "^10.71.0",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"cron-parser": "^5.10.2",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
"express": "^5.3.0",
"express-rate-limit": "^8.6.2",
"ics": "^3.12.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.2.0",
"multer": "^2.5.0",
"node-fetch": "^2.7.0",
"nodemailer": "^10.0.16",
"nodemailer": "^10.3.0",
"pdfkit": "^0.17.1",
"pg-boss": "^10.4.2",
"pg-boss": "^11.1.2",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"swagger-ui-dist": "^5.34.0",
"tsx": "^4.23.15",
"typescript": "^5.9.3",
"uuid": "^11.1.1",
"zod": "^4.4.3"
"zod": "^4.6.7"
},
"devDependencies": {
"@types/express": "^4.17.25",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.19.43",
@@ -72,5 +72,8 @@
"@parcel/watcher": false,
"@scarf/scarf": false,
"esbuild": false
},
"engines": {
"node": ">=22"
}
}
+14
View File
@@ -31,6 +31,9 @@ const getRawBody = require('raw-body');
// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
// Express 5 parses query strings the simple way by default; keep Express 4's (qs) so
// ?a[b]=1 style filters keep working.
app.set('query parser', 'extended');
// Trust proxy — required when running behind a reverse proxy (e.g. nginx on a
// separate server) so req.ip / X-Forwarded-For are read correctly by
@@ -98,6 +101,12 @@ const globalLimiter = rateLimit({
message: { message: 'Too many requests, please try again later.' },
skip: (req) => req.path.startsWith('/api/webhooks'),
});
// /api/v2 answers every error as { error: { code, message } }, including the shared
// middleware below (rate limits, read-only guards, auth) that writes v1's { message }.
app.use('/api/v2', require('./v2/errors').legacyErrorsToV2);
// v1 endpoints with a v2 replacement say so (Deprecation + Link: rel="successor-version").
app.use('/api', require('./v2/deprecation').deprecationHeaders);
app.use(globalLimiter);
// Impersonation is strictly read-only: refuse every write made with an impersonation
@@ -155,6 +164,11 @@ app.use('/api/webhooks', webhookRoutes);
// Apply JSON body parser for all non-webhook routes
app.use(express.json());
// Express 5 leaves req.body undefined when a request has no JSON body; handlers expect {}.
app.use((req, res, next) => {
if (req.body === undefined) req.body = {};
next();
});
// Count server errors (any 5xx response) for the Hub heartbeat.
app.use((req, res, next) => {
@@ -1,4 +1,5 @@
const ExcelJS = require('exceljs');
// ExcelTS: a maintained TypeScript port of exceljs (same API, no dependencies).
const ExcelJS = require('@cj-tech-master/excelts');
const PDFDocument = require('pdfkit');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
+23 -524
View File
@@ -5,115 +5,23 @@ const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const sessionsUtil = require('../utils/sessions');
const { publicOptions } = require('../utils/volunteers');
const { campusesEnabled, campusForEvent, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const { scopeFor, canView, canManage, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope');
const { campusesEnabled, campusWhere, resolveCampusFilter, EVENT_CAMPUS_SELECT } = require('../utils/campuses');
const { scopeFor, canView } = require('../utils/campusScope');
const seriesUtil = require('../utils/series');
const SERIES_SELECT = { select: { id: true, name: true, slug: true } };
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
async function assertCampusAllowed(req, campusId) {
const scope = await scopeFor(req);
if (scope && !canManage(scope, campusId)) {
const err = new Error(campusId == null
? 'Choose one of your campuses — events for all campuses are set up by admins and supervisors for every campus.'
: 'You can only put events at your own campuses.');
err.statusCode = 403;
throw err;
}
}
const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } };
// Adds the multi-day schedule to an event for API responses: sessions (cancelled ones
// flagged), when registration closes, the next session, and late-joiner pricing.
// `bookings` (Map sessionId → people booked) adds `booked` and `placesLeft` per session.
function withSchedule(ev, bookings = null) {
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({
...s,
cancelled: !!s.cancelledAt,
hasCheckIns: (_count?.usages || 0) > 0,
...(bookings ? { booked: bookings.get(s.id) || 0, placesLeft: s.capacity > 0 ? Math.max(0, s.capacity - (bookings.get(s.id) || 0)) : null } : {}),
}));
const active = sessionsUtil.activeSessions(sessions);
const now = new Date();
return {
...ev,
sessions,
registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions),
wholeCourseClosesAt: ev.scheduleType === 'sessions' ? sessionsUtil.wholeCourseClosesAt(ev, sessions) : null,
nextSession: ev.scheduleType === 'sessions' ? active.find(s => new Date(s.endsAt) > now) || null : null,
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
};
}
// The schedule/attendance fields of an event from a create/update body. `current` is the
// saved event on update (unsent fields keep their value).
function scheduleData(body, current = {}) {
const has = (k) => body[k] !== undefined;
const data = {};
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
}
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
if (has('lateFixedPrice')) {
const n = parseFloat(body.lateFixedPrice);
data.lateFixedPrice = body.lateFixedPrice === null || body.lateFixedPrice === '' || isNaN(n) ? null : Math.max(0, n);
}
if (has('sessionReminders')) data.sessionReminders = sessionsUtil.normalizeReminders(body.sessionReminders) ?? null;
if (has('certificatesEnabled')) data.certificatesEnabled = body.certificatesEnabled === true;
if (has('certificateConfig')) data.certificateConfig = sessionsUtil.normalizeCertificateConfig(body.certificateConfig);
const latePricing = data.latePricing ?? current.latePricing;
const lateFixedPrice = data.lateFixedPrice !== undefined ? data.lateFixedPrice : current.lateFixedPrice;
if (latePricing === 'fixed' && (lateFixedPrice === null || lateFixedPrice === undefined) && (data.lateJoinEnabled ?? current.lateJoinEnabled)) {
throw sessionsUtil.sessionError('Enter the fixed late price, or choose another late price option');
}
return data;
}
/**
* Saves the session list and the "late joining until" session after an event is created
* or updated, inside the caller's transaction (`tx`) so a refused change saves nothing.
* `lateJoinUntilSessionIndex` points into the sent session list (sorted by start).
* @returns {Promise<object|null>} the change counts, or null when nothing was sent
*/
async function saveSchedule(tx, eventId, body) {
sessionsUtil.forgetSchedule(eventId);
if (body.scheduleType === 'single') {
const used = await tx.ticketUsage.count({ where: { session: { eventId } } });
if (used > 0) throw sessionsUtil.sessionError("This event already has session check-ins, so it can't go back to a single date.");
await tx.eventSession.deleteMany({ where: { eventId } });
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: null } });
return null;
}
if (!Array.isArray(body.sessions)) return null;
const { sessions, changes } = await sessionsUtil.syncEventSessions(tx, eventId, body.sessions);
if (body.lateJoinUntilSessionIndex !== undefined) {
const idx = parseInt(body.lateJoinUntilSessionIndex, 10);
const ordered = [...sessions].sort((a, b) => a.order - b.order);
const until = Number.isInteger(idx) && ordered[idx] && !ordered[idx].cancelledAt ? ordered[idx].id : null;
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: until } });
}
return changes;
}
// Setting events up is shared with /api/v2/events (TypeScript, 1.51.7).
const eventService = require('../services/events');
const { SERIES_SELECT, SESSIONS_INCLUDE, withSchedule } = eventService;
// @desc Copy allocation groups (and, for admins, planned costs) from another event into a new copy
// @route POST /api/events/:id/copy-from
// @access Private/Supervisor
const copyEventSetupFrom = async (req, res) => {
try {
const fromEventId = req.body?.fromEventId;
if (!fromEventId) return res.status(400).json({ message: 'fromEventId is required' });
const copied = await require('../utils/eventClone').copyEventSetup(String(fromEventId), req.params.id, { withCosts: req.user?.role === 'admin' });
res.json(copied);
res.json(await eventService.copySetupFrom(req.params.id, req.body?.fromEventId, req.user));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
@@ -155,163 +63,7 @@ function toAbsoluteUrl(req, url) {
// @access Private/Admin
const createEvent = async (req, res) => {
try {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled() });
if (campusPick.error) { const err = new Error(campusPick.error); err.statusCode = 400; throw err; }
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
await assertCampusAllowed(req, campusPick.campusId === undefined ? null : campusPick.campusId);
const seriesId = await seriesUtil.seriesForEvent(req.body.seriesId);
const data = {
id: uuidv4(),
title,
description,
startDate: new Date(startDate),
endDate: new Date(endDate),
registrationDeadline: registrationDeadline ? new Date(registrationDeadline) : null,
goLiveAt: goLiveAt ? new Date(goLiveAt) : undefined,
price: parseFloat(price),
picture,
updatedAt: new Date(),
createdById: req.user?.id || undefined,
redirectUrl,
isHidden: isHidden === true || isHidden === 'true',
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
// A youth event needs names (with dates of birth), so it always takes them.
allowNamedAttendees: allowNamedAttendees === true || allowNamedAttendees === 'true' || requiresParentalConsent === true,
requiresParentalConsent: requiresParentalConsent === true,
consentText: consentText ? String(consentText) : null,
consentCollectMedical: consentCollectMedical === true,
consentCollectEmergency: consentCollectEmergency === true,
consentCollectPhoto: consentCollectPhoto === true,
waitlistEnabled: waitlistEnabled === true,
allowTicketTransfer: allowTicketTransfer !== false,
collectGender: collectGender === true,
allowSponsorships: allowSponsorships === true,
sponsorshipGoalSpots: sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null,
showSponsorshipProgress: showSponsorshipProgress !== false,
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
waitlistPartialOffers: waitlistPartialOffers !== false,
contactName: contactName || null,
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
location: location || null,
...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}),
...(seriesId ? { seriesId } : {}),
...scheduleData(req.body),
};
if (data.scheduleType === 'sessions') {
// Check the sessions before anything is saved; the dates come from them.
const bounds = sessionsUtil.scheduleBounds(sessionsUtil.normalizeSessionsInput(req.body.sessions).filter(s => !s.cancelled));
data.startDate = bounds.startDate;
data.endDate = bounds.endDate;
}
const logEventCreate = (createdEvent) => logAdminAction({
actorId: req.user?.id,
actorRole: req.user?.role,
action: 'event_created',
targetType: 'Event',
targetId: createdEvent.id,
metadata: { title: createdEvent.title, ...(req.body.clonedFromId ? { clonedFromId: String(req.body.clonedFromId) } : {}) },
ip: getClientIp(req),
});
try {
const event = await prisma.event.create({ data });
logEventCreate(event);
if (data.scheduleType === 'sessions') await prisma.$transaction(tx => saveSchedule(tx, event.id, req.body));
// Automatically create a main ticket (event option) with the event price — contact-only
// events have no bookable options, so there's nothing to auto-create for them.
try {
if (data.requiresRegistration !== false && prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') {
await prisma.eventOption.create({
data: {
id: uuidv4(),
eventId: event.id,
name: 'Main Ticket',
price: event.price || 0,
isMainTicket: true,
}
});
}
} catch (e) {
// Non-fatal: event is created even if option creation fails
try { console.warn('[createEvent] Failed to auto-create main ticket option:', e?.message || e); } catch {}
}
// Create form if provided
try {
const formInput = req.body?.form;
if (formInput && prisma && prisma.eventForm) {
const createdForm = await prisma.eventForm.create({
data: {
id: uuidv4(),
eventId: event.id,
isRequired: !!formInput.isRequired,
}
});
const fields = Array.isArray(formInput.fields) ? formInput.fields : [];
for (let i = 0; i < fields.length; i++) {
const f = fields[i];
if (!f || !f.type || !f.label) continue;
await prisma.eventFormField.create({
data: {
id: uuidv4(),
formId: createdForm.id,
type: f.type,
label: String(f.label),
isRequired: !!f.isRequired,
order: typeof f.order === 'number' ? f.order : i,
helpText: f.helpText || null,
options: f.options || undefined,
}
});
}
}
} catch (e) {
const msg = String(e?.message || e || '');
try { console.error('[createEvent] Failed to save form/fields:', msg); } catch {}
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
}
return res.status(201).json(withSchedule(await prisma.event.findUnique({ where: { id: event.id }, include: { sessions: SESSIONS_INCLUDE } })));
} catch (err) {
// Gracefully handle environments where migration isn't applied yet
const msg = String(err?.message || '');
if (msg.includes('Unknown argument `registrationDeadline`')) {
// @ts-ignore delete field and retry
delete data.registrationDeadline;
const event = await prisma.event.create({ data });
logEventCreate(event);
return res.status(201).json(event);
}
if (msg.includes('Unknown argument `goLiveAt`')) {
// @ts-ignore delete field and retry
delete data.goLiveAt;
const event = await prisma.event.create({ data });
logEventCreate(event);
return res.status(201).json(event);
}
if (msg.includes('Unknown argument `createdById`')) {
// @ts-ignore delete field and retry
delete data.createdById;
const event = await prisma.event.create({ data });
logEventCreate(event);
return res.status(201).json(event);
}
if (msg.includes('Unknown argument `redirectUrl`')) {
// @ts-ignore delete field and retry
delete data.redirectUrl;
const event = await prisma.event.create({ data });
logEventCreate(event);
return res.status(201).json(event);
}
throw err;
}
res.status(201).json(await eventService.createEvent(req.body || {}, req.user, { scope: await scopeFor(req), ip: getClientIp(req) }));
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
@@ -453,44 +205,15 @@ const getAllEvents = async (req, res) => {
// @access Private
const getEventsAll = async (req, res) => {
try {
const includePast = req.query.includePast === 'true';
const includeInactive = req.query.includeInactive === 'true';
const excludeClosed = req.query.excludeClosed === 'true';
const where = {};
if (!includeInactive) where.isActive = true;
if (!includePast) where.endDate = { gte: new Date() };
if (excludeClosed) where.cashupStatus = { not: 'closed' };
// ?campus=<id, slug or 'all'>: one campus's events, or only church-wide ones.
const enabled = await campusesEnabled();
Object.assign(where, campusWhere(enabled ? await resolveCampusFilter(req.query.campus) : '', { enabled }));
const scoped = withScope(where, scopeEventWhere(await scopeFor(req)));
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
const optionInclude = canIncludeTiers
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) }
: undefined;
const events = await prisma.event.findMany({
where: scoped,
include: {
eventOptions: optionInclude ? { include: optionInclude } : true,
form: { include: { fields: true } },
sessions: SESSIONS_INCLUDE,
campus: { select: EVENT_CAMPUS_SELECT },
series: SERIES_SELECT,
},
orderBy: { startDate: 'asc' }
});
const mapped = events.map(ev => withSchedule({
...ev,
picture: toAbsoluteUrl(req, ev.picture),
}));
res.json(mapped);
const events = await eventService.listManagedEvents({
includePast: req.query.includePast === 'true',
includeInactive: req.query.includeInactive === 'true',
excludeClosed: req.query.excludeClosed === 'true',
campus: req.query.campus,
}, { scope: await scopeFor(req) });
res.json(events.map(ev => ({ ...ev, picture: toAbsoluteUrl(req, ev.picture) })));
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -723,181 +446,9 @@ const getEventIcs = async (req, res) => {
// @access Private/Admin
const updateEvent = async (req, res) => {
try {
const event = await prisma.event.findUnique({
where: { id: req.params.id }
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
// A closed (cashed-up) event's pricing/structure must not change under the reconciled
// totals — same rule already enforced for payments/costs. Admin can reopen first.
await assertEventOpen(req.params.id, res);
const logEventUpdate = () => logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'event_updated',
targetType: 'Event',
targetId: req.params.id,
metadata: { changedKeys: Object.keys(req.body || {}) },
ip: getClientIp(req),
});
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
const campusPick = await campusForEvent(req.body.campusId, { enabled: await campusesEnabled(), currentCampusId: event.campusId });
if (campusPick.error) throw new Error(campusPick.error);
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
const seriesPick = await seriesUtil.seriesForEvent(req.body.seriesId);
// Moved into another series: that series' followers hear about it (once it's live).
const seriesChanged = seriesPick !== undefined && seriesPick !== event.seriesId;
if (campusChanged) {
try { await assertCampusAllowed(req, campusPick.campusId); } catch (e) { return res.status(403).json({ message: e.message }); }
}
const data = {
title: title || event.title,
description: description !== undefined ? description : event.description,
startDate: startDate ? new Date(startDate) : event.startDate,
endDate: endDate ? new Date(endDate) : event.endDate,
registrationDeadline: registrationDeadline !== undefined ? (registrationDeadline ? new Date(registrationDeadline) : null) : event.registrationDeadline,
goLiveAt: goLiveAt !== undefined ? (goLiveAt ? new Date(goLiveAt) : new Date()) : (event.goLiveAt || undefined),
price: price ? parseFloat(price) : event.price,
picture: picture !== undefined ? picture : event.picture,
isActive: isActive !== undefined ? isActive : event.isActive,
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true),
requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true),
allowNamedAttendees: (requiresParentalConsent ?? event.requiresParentalConsent) === true
? true
: (allowNamedAttendees !== undefined ? (allowNamedAttendees === true || allowNamedAttendees === 'true') : event.allowNamedAttendees),
requiresParentalConsent: requiresParentalConsent !== undefined ? requiresParentalConsent === true : event.requiresParentalConsent,
consentCollectMedical: consentCollectMedical !== undefined ? consentCollectMedical === true : event.consentCollectMedical,
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer,
collectGender: collectGender !== undefined ? collectGender === true : event.collectGender,
allowSponsorships: allowSponsorships !== undefined ? allowSponsorships === true : event.allowSponsorships,
sponsorshipGoalSpots: sponsorshipGoalSpots !== undefined ? (sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null) : event.sponsorshipGoalSpots,
showSponsorshipProgress: showSponsorshipProgress !== undefined ? showSponsorshipProgress !== false : event.showSponsorshipProgress,
waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours,
waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers,
// Editing the consent text starts a new version; parents who already agreed keep
// their record of the old one (supervisors can re-request consent).
...(consentText !== undefined && String(consentText || '') !== String(event.consentText || '')
? { consentText: consentText ? String(consentText) : null, consentTextVersion: (event.consentTextVersion || 1) + 1 }
: {}),
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
location: location !== undefined ? (location || null) : event.location,
...(campusChanged ? { campusId: campusPick.campusId } : {}),
...(seriesChanged ? { seriesId: seriesPick, seriesAnnouncedAt: null } : {}),
updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
...scheduleData(req.body, event),
};
// A multi-session event's dates come from its sessions (set by saveSchedule).
if ((data.scheduleType ?? event.scheduleType) === 'sessions') {
data.startDate = event.startDate;
data.endDate = event.endDate;
if (Array.isArray(req.body.sessions)) sessionsUtil.normalizeSessionsInput(req.body.sessions);
else if (event.scheduleType !== 'sessions') throw sessionsUtil.sessionError('Add the sessions for a multi-session event');
}
try {
// Event fields and sessions save together: a refused schedule change saves nothing.
const scheduleBody = data.scheduleType === 'single' && event.scheduleType === 'sessions' ? { scheduleType: 'single' } : req.body;
let updatedEvent;
let sessionChanges;
await prisma.$transaction(async (tx) => {
updatedEvent = await tx.event.update({ where: { id: req.params.id }, data });
sessionChanges = updatedEvent.scheduleType === 'sessions' || scheduleBody.scheduleType === 'single'
? await saveSchedule(tx, updatedEvent.id, scheduleBody)
: null;
});
sessionsUtil.forgetSchedule(updatedEvent.id);
if (campusChanged) {
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'event_campus_changed',
targetType: 'Event', targetId: updatedEvent.id, metadata: { from: event.campusId, to: campusPick.campusId }, ip: getClientIp(req),
});
}
if (sessionChanges && Object.values(sessionChanges).some(n => n > 0)) {
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'event_sessions_changed',
targetType: 'Event', targetId: updatedEvent.id, metadata: sessionChanges, ip: getClientIp(req),
});
}
updatedEvent = withSchedule(await prisma.event.findUnique({ where: { id: updatedEvent.id }, include: { sessions: SESSIONS_INCLUDE } }));
updatedEvent.sessionChanges = sessionChanges;
// Upsert form if provided
try {
const formInput = req.body?.form;
if (formInput && prisma && prisma.eventForm) {
// find existing form
let existing = null;
try { existing = await prisma.eventForm.findUnique({ where: { eventId: updatedEvent.id } }); } catch {}
if (!existing) {
existing = await prisma.eventForm.create({ data: { id: uuidv4(), eventId: updatedEvent.id, isRequired: !!formInput.isRequired } });
} else {
await prisma.eventForm.update({ where: { id: existing.id }, data: { isRequired: !!formInput.isRequired } });
}
const formId = existing.id;
// Replace fields: simple approach — delete all and recreate
try { await prisma.eventFormField.deleteMany({ where: { formId } }); } catch {}
const fields = Array.isArray(formInput.fields) ? formInput.fields : [];
for (let i = 0; i < fields.length; i++) {
const f = fields[i];
if (!f || !f.type || !f.label) continue;
await prisma.eventFormField.create({
data: {
id: uuidv4(),
formId,
type: f.type,
label: String(f.label),
isRequired: !!f.isRequired,
order: typeof f.order === 'number' ? f.order : i,
helpText: f.helpText || null,
options: f.options || undefined,
}
});
}
}
} catch (e) {
const msg = String(e?.message || e || '');
try { console.error('[updateEvent] Failed to save form/fields:', msg); } catch {}
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
}
logEventUpdate();
return res.json(updatedEvent);
} catch (err) {
const msg = String(err?.message || '');
if (msg.includes('Unknown argument `registrationDeadline`')) {
// @ts-ignore
delete data.registrationDeadline;
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
logEventUpdate();
return res.json(updatedEvent);
}
if (msg.includes('Unknown argument `goLiveAt`')) {
// @ts-ignore
delete data.goLiveAt;
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
logEventUpdate();
return res.json(updatedEvent);
}
throw err;
}
res.json(await eventService.updateEvent(req.params.id, req.body || {}, req.user, { scope: await scopeFor(req), ip: getClientIp(req) }));
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -909,17 +460,9 @@ const updateEvent = async (req, res) => {
// @access Private/Supervisor
const getEventNotifyRecipients = async (req, res) => {
try {
const event = await prisma.event.findUnique({
where: { id: req.params.id },
select: { notifyRecipients: { select: { id: true, name: true, email: true, role: true } } },
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
res.json(event.notifyRecipients);
res.json(await eventService.getNotifyRecipients(req.params.id));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -929,27 +472,9 @@ const getEventNotifyRecipients = async (req, res) => {
// @access Private/Supervisor
const updateEventNotifyRecipients = async (req, res) => {
try {
const { userIds } = req.body;
if (!Array.isArray(userIds)) {
res.status(400);
throw new Error('userIds must be an array');
}
const event = await prisma.event.findUnique({ where: { id: req.params.id } });
if (!event) {
res.status(404);
throw new Error('Event not found');
}
const updated = await prisma.event.update({
where: { id: req.params.id },
data: { notifyRecipients: { set: userIds.map(id => ({ id })) } },
include: { notifyRecipients: { select: { id: true, name: true, email: true, role: true } } },
});
res.json(updated.notifyRecipients);
res.json(await eventService.setNotifyRecipients(req.params.id, req.body?.userIds));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
@@ -958,36 +483,10 @@ const updateEventNotifyRecipients = async (req, res) => {
// @access Private/Admin
const deleteEvent = async (req, res) => {
try {
const event = await prisma.event.findUnique({
where: { id: req.params.id }
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
// Instead of deleting, we set isActive to false
await prisma.event.update({
where: { id: req.params.id },
data: {
isActive: false,
updatedAt: new Date()
}
});
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'event_deleted',
targetType: 'Event',
targetId: req.params.id,
ip: getClientIp(req),
});
await eventService.deactivateEvent(req.params.id, req.user, { scope: await scopeFor(req), ip: getClientIp(req) });
res.json({ message: 'Event deactivated' });
} catch (error) {
res.status(400).json({ message: error.message });
res.status(error.statusCode || 400).json({ message: error.message });
}
};
+2 -1
View File
@@ -1,5 +1,6 @@
const PDFDocument = require('pdfkit');
const ExcelJS = require('exceljs');
// ExcelTS: a maintained TypeScript port of exceljs (same API, no dependencies).
const ExcelJS = require('@cj-tech-master/excelts');
const fs = require('fs');
const path = require('path');
const { sendMail, emailWrapper } = require('../utils/email');
+9 -9
View File
@@ -15,10 +15,13 @@
* same jobs run on in-process timers instead, as before.
*/
const cronParser = require('cron-parser');
const { CronExpressionParser } = require('cron-parser');
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
const SCHEMA = 'pgboss';
// pg-boss 11 can't upgrade pg-boss 10's schema (it migrates only from 11.0), so it has its own.
// The old `pgboss` schema (10.x) is left as it was; it can be dropped (DROP SCHEMA pgboss CASCADE)
// once 1.51.7 has run — it only held queued runs and their history.
const SCHEMA = 'pgboss_v11';
const JOBS = [
{
@@ -105,7 +108,7 @@ function cronFor(job) {
const override = process.env[`JOB_SCHEDULE_${job.name.toUpperCase().replace(/-/g, '_')}`];
if (override) {
try {
cronParser.parseExpression(override, { tz: TIMEZONE });
CronExpressionParser.parse(override, { tz: TIMEZONE });
return override;
} catch {
console.warn(`[jobs] Ignoring invalid schedule for ${job.name}: "${override}"`);
@@ -116,7 +119,7 @@ function cronFor(job) {
/** When a cron schedule next fires after `from`. */
function nextRun(cron, from = new Date()) {
return cronParser.parseExpression(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
return CronExpressionParser.parse(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
}
function summarize(result) {
@@ -270,11 +273,8 @@ async function jobStatus(prisma) {
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
-- pg-boss 11 keeps finished runs in job until they expire (there's no archive table).
FROM (SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job) all_jobs
WHERE name = ANY($1::text[])
) ranked
WHERE rn <= 10
+411
View File
@@ -0,0 +1,411 @@
/**
* Setting events up (1.51.7): create, edit, deactivate, the staff list, notification recipients
* and copying set-up from another event. Moved from eventController.js so /api/events (v1) and
* /api/v2/events share it.
*/
import { v4 as uuidv4 } from 'uuid';
import type { Prisma } from '@prisma/client';
import { prisma } from '../v2/legacy';
import { fail, ServiceError } from './errors';
import type { Role } from './registrations';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
type CampusScope = object | null;
const sessionsUtil = require('../utils/sessions') as {
sessionError(message: string): Error;
normalizeReminders(v: unknown): unknown;
normalizeCertificateConfig(v: unknown): unknown;
normalizeSessionsInput(v: unknown): Array<{ cancelled?: boolean } & Record<string, unknown>>;
scheduleBounds(sessions: unknown[]): { startDate: Date; endDate: Date };
forgetSchedule(eventId: string): void;
syncEventSessions(tx: any, eventId: string, sessions: unknown[]): Promise<{ sessions: Array<{ id: string; order: number; cancelledAt: Date | null }>; changes: Record<string, number> }>;
activeSessions(sessions: any[]): any[];
registrationClosesAt(ev: any, sessions: any[]): Date;
wholeCourseClosesAt(ev: any, sessions: any[]): Date;
lateInfo(ev: any, sessions: any[], now: Date): unknown;
};
const campuses = require('../utils/campuses') as {
campusesEnabled(): Promise<boolean>;
campusForEvent(campusId: unknown, opts: { enabled: boolean; currentCampusId?: string | null }): Promise<{ campusId?: string | null; error?: string }>;
campusWhere(filter: string, opts: { enabled: boolean; withAllCampuses?: boolean }): object;
resolveCampusFilter(value: unknown): Promise<string>;
EVENT_CAMPUS_SELECT: Record<string, true>;
};
const campusScope = require('../utils/campusScope') as {
canManage(scope: object, campusId: string | null): boolean;
eventWhere(scope: CampusScope): object;
withScope(where: object, scopeWhere: object): object;
};
const seriesUtil = require('../utils/series') as { seriesForEvent(seriesId: unknown): Promise<string | null | undefined> };
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
export type Actor = { id: string; role: Role };
/** The caller's campus scope (utils/campusScope.scopeFor) and IP, for checks and the audit log. */
export type Ctx = { scope: CampusScope; ip?: string };
/** An event body as the event wizard sends it (v1 and v2 take the same fields). */
export type EventInput = Record<string, any>;
export const SERIES_SELECT = { select: { id: true, name: true, slug: true } } as const;
export const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' as const }, include: { _count: { select: { usages: true } } } };
/** A campus-scoped supervisor may only create or move events to one of their campuses. */
export function assertCampusAllowed(scope: CampusScope, campusId: string | null): void {
if (scope && !campusScope.canManage(scope, campusId)) {
fail(403, campusId == null
? 'Choose one of your campuses — events for all campuses are set up by admins and supervisors for every campus.'
: 'You can only put events at your own campuses.');
}
}
/**
* Adds the multi-day schedule to an event for API responses: sessions (cancelled ones flagged),
* when registration closes, the next session, and late-joiner pricing. `bookings` (Map
* sessionId → people booked) adds `booked` and `placesLeft` per session.
*/
export function withSchedule<E extends { sessions?: any[]; scheduleType?: string }>(ev: E, bookings: Map<string, number> | null = null) {
const sessions = (ev.sessions || []).map(({ _count, ...s }: any) => ({
...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: any) => new Date(s.endsAt) > now) || null : null,
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
};
}
/** The schedule/attendance fields from a create/update body. `current` is the saved event on update. */
export function scheduleData(body: EventInput, current: Record<string, any> = {}): Record<string, any> {
const has = (k: string) => body[k] !== undefined;
const data: Record<string, any> = {};
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
}
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
if (has('lateFixedPrice')) {
const n = parseFloat(body.lateFixedPrice);
data.lateFixedPrice = body.lateFixedPrice === null || body.lateFixedPrice === '' || isNaN(n) ? null : Math.max(0, n);
}
if (has('sessionReminders')) data.sessionReminders = sessionsUtil.normalizeReminders(body.sessionReminders) ?? null;
if (has('certificatesEnabled')) data.certificatesEnabled = body.certificatesEnabled === true;
if (has('certificateConfig')) data.certificateConfig = sessionsUtil.normalizeCertificateConfig(body.certificateConfig);
const latePricing = data.latePricing ?? current.latePricing;
const lateFixedPrice = data.lateFixedPrice !== undefined ? data.lateFixedPrice : current.lateFixedPrice;
if (latePricing === 'fixed' && (lateFixedPrice === null || lateFixedPrice === undefined) && (data.lateJoinEnabled ?? current.lateJoinEnabled)) {
throw sessionsUtil.sessionError('Enter the fixed late price, or choose another late price option');
}
return data;
}
/**
* Saves the session list and the "late joining until" session, inside the caller's transaction
* so a refused change saves nothing. Returns the change counts, or null when nothing was sent.
*/
export async function saveSchedule(tx: Prisma.TransactionClient, eventId: string, body: EventInput): Promise<Record<string, number> | null> {
sessionsUtil.forgetSchedule(eventId);
if (body.scheduleType === 'single') {
const used = await tx.ticketUsage.count({ where: { session: { eventId } } });
if (used > 0) throw sessionsUtil.sessionError("This event already has session check-ins, so it can't go back to a single date.");
await tx.eventSession.deleteMany({ where: { eventId } });
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: null } });
return null;
}
if (!Array.isArray(body.sessions)) return null;
const { sessions, changes } = await sessionsUtil.syncEventSessions(tx, eventId, body.sessions);
if (body.lateJoinUntilSessionIndex !== undefined) {
const idx = parseInt(body.lateJoinUntilSessionIndex, 10);
const ordered = [...sessions].sort((a, b) => a.order - b.order);
const until = Number.isInteger(idx) && ordered[idx] && !ordered[idx].cancelledAt ? ordered[idx].id : null;
await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: until } });
}
return changes;
}
const yes = (v: unknown) => v === true || v === 'true';
const notNo = (v: unknown) => !(v === false || v === 'false');
/** Replaces the event's attendee form with the one sent (`{ isRequired, fields[] }`). */
async function saveForm(eventId: string, formInput: any): Promise<void> {
if (!formInput) return;
try {
let form = await prisma.eventForm.findUnique({ where: { eventId } });
if (!form) form = await prisma.eventForm.create({ data: { id: uuidv4(), eventId, isRequired: !!formInput.isRequired } });
else await prisma.eventForm.update({ where: { id: form.id }, data: { isRequired: !!formInput.isRequired } });
await prisma.eventFormField.deleteMany({ where: { formId: form.id } });
const fields = Array.isArray(formInput.fields) ? formInput.fields : [];
for (let i = 0; i < fields.length; i++) {
const f = fields[i];
if (!f || !f.type || !f.label) continue;
await prisma.eventFormField.create({
data: {
id: uuidv4(), formId: form.id, type: f.type, label: String(f.label), isRequired: !!f.isRequired,
order: typeof f.order === 'number' ? f.order : i, helpText: f.helpText || null, options: f.options || undefined,
},
});
}
} catch (e: any) {
console.error('[events] Failed to save form/fields:', e?.message || e);
fail(400, 'Failed to save event form/fields');
}
}
/** Turns a helper's plain Error (session rules, series, campus) into a 400, like v1 did. */
async function asClientErrors<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e: any) {
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
throw new ServiceError(e?.statusCode || 400, e?.message || 'The event could not be saved');
}
}
/**
* A new event, with a "Main Ticket" at the event price (unless it's contact-only), its sessions
* and its attendee form. A campus-scoped supervisor must put it at one of their campuses.
*/
export function createEvent(body: EventInput, actor: Actor, ctx: Ctx) {
return asClientErrors(async () => {
const campusPick = await campuses.campusForEvent(body.campusId, { enabled: await campuses.campusesEnabled() });
if (campusPick.error) fail(400, campusPick.error);
// Not choosing a campus means all campuses, which a scoped supervisor can't create.
assertCampusAllowed(ctx.scope, campusPick.campusId === undefined ? null : campusPick.campusId);
const seriesId = await seriesUtil.seriesForEvent(body.seriesId);
const data: Record<string, any> = {
id: uuidv4(),
title: body.title,
description: body.description,
startDate: new Date(body.startDate),
endDate: new Date(body.endDate),
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null,
goLiveAt: body.goLiveAt ? new Date(body.goLiveAt) : undefined,
price: parseFloat(body.price),
picture: body.picture,
updatedAt: new Date(),
createdById: actor.id,
redirectUrl: body.redirectUrl,
isHidden: yes(body.isHidden),
requiresAuth: notNo(body.requiresAuth),
requiresRegistration: notNo(body.requiresRegistration),
// A youth event needs names (with dates of birth), so it always takes them.
allowNamedAttendees: yes(body.allowNamedAttendees) || body.requiresParentalConsent === true,
requiresParentalConsent: body.requiresParentalConsent === true,
consentText: body.consentText ? String(body.consentText) : null,
consentCollectMedical: body.consentCollectMedical === true,
consentCollectEmergency: body.consentCollectEmergency === true,
consentCollectPhoto: body.consentCollectPhoto === true,
waitlistEnabled: body.waitlistEnabled === true,
allowTicketTransfer: body.allowTicketTransfer !== false,
collectGender: body.collectGender === true,
allowSponsorships: body.allowSponsorships === true,
sponsorshipGoalSpots: body.sponsorshipGoalSpots ? Math.max(1, parseInt(body.sponsorshipGoalSpots, 10) || 0) || null : null,
showSponsorshipProgress: body.showSponsorshipProgress !== false,
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(body.waitlistOfferHours, 10) || 24)),
waitlistPartialOffers: body.waitlistPartialOffers !== false,
contactName: body.contactName || null,
contactPhone: body.contactPhone || null,
contactEmail: body.contactEmail || null,
location: body.location || null,
...(campusPick.campusId !== undefined ? { campusId: campusPick.campusId } : {}),
...(seriesId ? { seriesId } : {}),
...scheduleData(body),
};
if (data.scheduleType === 'sessions') {
// Check the sessions before anything is saved; the dates come from them.
const bounds = sessionsUtil.scheduleBounds(sessionsUtil.normalizeSessionsInput(body.sessions).filter(s => !s.cancelled));
data.startDate = bounds.startDate;
data.endDate = bounds.endDate;
}
const event = await prisma.event.create({ data: data as Prisma.EventUncheckedCreateInput });
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'event_created', targetType: 'Event', targetId: event.id,
metadata: { title: event.title, ...(body.clonedFromId ? { clonedFromId: String(body.clonedFromId) } : {}) }, ip: ctx.ip,
});
if (data.scheduleType === 'sessions') await prisma.$transaction(tx => saveSchedule(tx, event.id, body));
// A main ticket at the event price — contact-only events have nothing to book.
if (data.requiresRegistration !== false) {
await prisma.eventOption.create({ data: { id: uuidv4(), eventId: event.id, name: 'Main Ticket', price: event.price || 0, isMainTicket: true } })
.catch((e: any) => console.warn('[events] Failed to auto-create main ticket option:', e?.message || e));
}
await saveForm(event.id, body.form);
return withSchedule(await prisma.event.findUniqueOrThrow({ where: { id: event.id }, include: { sessions: SESSIONS_INCLUDE, eventOptions: true } }));
});
}
/**
* Edits an event: only the fields sent change. Its sessions save in the same transaction (a
* refused schedule change saves nothing); a changed consent text starts a new version; moving it
* to another series lets that series' followers hear about it. Closed (cashed-up) events can't
* change.
*/
export function updateEvent(eventId: string, body: EventInput, actor: Actor, ctx: Ctx) {
return asClientErrors(async () => {
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) return fail(404, 'Event not found');
// A closed (cashed-up) event's pricing/structure must not change under the reconciled totals.
await assertEventOpen(eventId);
const campusPick = await campuses.campusForEvent(body.campusId, { enabled: await campuses.campusesEnabled(), currentCampusId: event.campusId });
if (campusPick.error) fail(400, campusPick.error);
const campusChanged = campusPick.campusId !== undefined && campusPick.campusId !== event.campusId;
const seriesPick = await seriesUtil.seriesForEvent(body.seriesId);
// Moved into another series: that series' followers hear about it (once it's live).
const seriesChanged = seriesPick !== undefined && seriesPick !== event.seriesId;
if (campusChanged) assertCampusAllowed(ctx.scope, campusPick.campusId ?? null);
const keep = <K extends keyof typeof event>(k: K, v: unknown, map: (x: any) => unknown = x => x) => (v !== undefined ? map(v) : event[k]);
const requiresParentalConsent = body.requiresParentalConsent ?? event.requiresParentalConsent;
const data: Record<string, any> = {
title: body.title || event.title,
description: keep('description', body.description),
startDate: body.startDate ? new Date(body.startDate) : event.startDate,
endDate: body.endDate ? new Date(body.endDate) : event.endDate,
registrationDeadline: keep('registrationDeadline', body.registrationDeadline, v => (v ? new Date(v) : null)),
goLiveAt: body.goLiveAt !== undefined ? (body.goLiveAt ? new Date(body.goLiveAt) : new Date()) : (event.goLiveAt || undefined),
price: body.price ? parseFloat(body.price) : event.price,
picture: keep('picture', body.picture),
isActive: keep('isActive', body.isActive),
isHidden: body.isHidden !== undefined ? yes(body.isHidden) : (event.isHidden ?? false),
requiresAuth: body.requiresAuth !== undefined ? notNo(body.requiresAuth) : (event.requiresAuth ?? true),
requiresRegistration: body.requiresRegistration !== undefined ? notNo(body.requiresRegistration) : (event.requiresRegistration ?? true),
allowNamedAttendees: requiresParentalConsent === true ? true : (body.allowNamedAttendees !== undefined ? yes(body.allowNamedAttendees) : event.allowNamedAttendees),
requiresParentalConsent: keep('requiresParentalConsent', body.requiresParentalConsent, v => v === true),
consentCollectMedical: keep('consentCollectMedical', body.consentCollectMedical, v => v === true),
consentCollectEmergency: keep('consentCollectEmergency', body.consentCollectEmergency, v => v === true),
consentCollectPhoto: keep('consentCollectPhoto', body.consentCollectPhoto, v => v === true),
waitlistEnabled: keep('waitlistEnabled', body.waitlistEnabled, v => v === true),
allowTicketTransfer: keep('allowTicketTransfer', body.allowTicketTransfer, v => v !== false),
collectGender: keep('collectGender', body.collectGender, v => v === true),
allowSponsorships: keep('allowSponsorships', body.allowSponsorships, v => v === true),
sponsorshipGoalSpots: keep('sponsorshipGoalSpots', body.sponsorshipGoalSpots, v => (v ? Math.max(1, parseInt(v, 10) || 0) || null : null)),
showSponsorshipProgress: keep('showSponsorshipProgress', body.showSponsorshipProgress, v => v !== false),
waitlistOfferHours: keep('waitlistOfferHours', body.waitlistOfferHours, v => Math.min(168, Math.max(1, parseInt(v, 10) || 24))),
waitlistPartialOffers: keep('waitlistPartialOffers', body.waitlistPartialOffers, v => v !== false),
// Editing the consent text starts a new version; parents who already agreed keep their
// record of the old one (supervisors can re-request consent).
...(body.consentText !== undefined && String(body.consentText || '') !== String(event.consentText || '')
? { consentText: body.consentText ? String(body.consentText) : null, consentTextVersion: (event.consentTextVersion || 1) + 1 }
: {}),
contactName: keep('contactName', body.contactName, v => v || null),
contactPhone: keep('contactPhone', body.contactPhone, v => v || null),
contactEmail: keep('contactEmail', body.contactEmail, v => v || null),
location: keep('location', body.location, v => v || null),
...(campusChanged ? { campusId: campusPick.campusId } : {}),
...(seriesChanged ? { seriesId: seriesPick, seriesAnnouncedAt: null } : {}),
updatedAt: new Date(),
redirectUrl: keep('redirectUrl', body.redirectUrl),
...scheduleData(body, event),
};
// A multi-session event's dates come from its sessions (set by saveSchedule).
if ((data.scheduleType ?? event.scheduleType) === 'sessions') {
data.startDate = event.startDate;
data.endDate = event.endDate;
if (Array.isArray(body.sessions)) sessionsUtil.normalizeSessionsInput(body.sessions);
else if (event.scheduleType !== 'sessions') throw sessionsUtil.sessionError('Add the sessions for a multi-session event');
}
// Event fields and sessions save together: a refused schedule change saves nothing.
const scheduleBody = data.scheduleType === 'single' && event.scheduleType === 'sessions' ? { scheduleType: 'single' } : body;
let sessionChanges: Record<string, number> | null = null;
const saved = await prisma.$transaction(async (tx) => {
const updated = await tx.event.update({ where: { id: eventId }, data });
sessionChanges = updated.scheduleType === 'sessions' || scheduleBody.scheduleType === 'single' ? await saveSchedule(tx, updated.id, scheduleBody) : null;
return updated;
});
sessionsUtil.forgetSchedule(saved.id);
if (campusChanged) {
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_campus_changed', targetType: 'Event', targetId: saved.id, metadata: { from: event.campusId, to: campusPick.campusId }, ip: ctx.ip });
}
const changes = sessionChanges as Record<string, number> | null;
if (changes && Object.values(changes).some(n => n > 0)) {
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_sessions_changed', targetType: 'Event', targetId: saved.id, metadata: changes, ip: ctx.ip });
}
await saveForm(saved.id, body.form);
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_updated', targetType: 'Event', targetId: eventId, metadata: { changedKeys: Object.keys(body || {}) }, ip: ctx.ip });
const fresh = withSchedule(await prisma.event.findUniqueOrThrow({ where: { id: saved.id }, include: { sessions: SESSIONS_INCLUDE } }));
return { ...fresh, sessionChanges: changes };
});
}
/** "Deleting" an event deactivates it (registrations, payments and tickets stay). Admins only. */
export async function deactivateEvent(eventId: string, actor: Actor, ctx: Ctx) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) return fail(404, 'Event not found');
const updated = await prisma.event.update({ where: { id: eventId }, data: { isActive: false, updatedAt: new Date() } });
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'event_deleted', targetType: 'Event', targetId: eventId, ip: ctx.ip });
return updated;
}
export type ManagedListOptions = { includePast?: boolean; includeInactive?: boolean; excludeClosed?: boolean; campus?: string };
/**
* Every event staff may see (hidden ones too) — upcoming by default; past, inactive and
* closed (cashed-up) on request; limited to their campuses when they're campus-scoped. With
* ticket options (tiers, variants), the attendee form, sessions, campus and series.
*/
export async function listManagedEvents(opts: ManagedListOptions, ctx: Ctx) {
const where: Record<string, any> = {};
if (!opts.includeInactive) where.isActive = true;
if (!opts.includePast) where.endDate = { gte: new Date() };
if (opts.excludeClosed) where.cashupStatus = { not: 'closed' };
// ?campus=<id, slug or 'all'>: one campus's events, or only church-wide ones.
const enabled = await campuses.campusesEnabled();
Object.assign(where, campuses.campusWhere(enabled ? await campuses.resolveCampusFilter(opts.campus) : '', { enabled }));
const scoped = campusScope.withScope(where, campusScope.eventWhere(ctx.scope));
const events = await prisma.event.findMany({
where: scoped,
include: {
eventOptions: { include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } } },
form: { include: { fields: true } },
sessions: SESSIONS_INCLUDE,
campus: { select: campuses.EVENT_CAMPUS_SELECT },
series: SERIES_SELECT,
},
orderBy: { startDate: 'asc' },
});
return events.map(ev => withSchedule(ev));
}
const RECIPIENT_SELECT = { id: true, name: true, email: true, role: true } as const;
/** Who gets this event's registration, payment and daily-summary emails. */
export async function getNotifyRecipients(eventId: string) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { notifyRecipients: { select: RECIPIENT_SELECT } } });
if (!event) return fail(404, 'Event not found');
return event.notifyRecipients;
}
export async function setNotifyRecipients(eventId: string, userIds: string[]) {
if (!Array.isArray(userIds)) fail(400, 'userIds must be an array');
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) return fail(404, 'Event not found');
const updated = await prisma.event.update({
where: { id: eventId },
data: { notifyRecipients: { set: userIds.map(id => ({ id })) } },
include: { notifyRecipients: { select: RECIPIENT_SELECT } },
});
return updated.notifyRecipients;
}
/** Copies allocation groups (and, for admins, planned costs) from another event into this one. */
export async function copySetupFrom(eventId: string, fromEventId: string, actor: Actor) {
if (!fromEventId) fail(400, 'fromEventId is required');
const { copyEventSetup } = require('../utils/eventClone') as { copyEventSetup(from: string, to: string, opts: { withCosts: boolean }): Promise<unknown> };
return copyEventSetup(String(fromEventId), eventId, { withCosts: actor.role === 'admin' });
}
+60
View File
@@ -0,0 +1,60 @@
import type { RequestHandler } from 'express';
/**
* v1 endpoints that have a v2 replacement say so (1.51.3): a `Deprecation` header (RFC 9745,
* the date v2 covered them) and `Link: <…>; rel="successor-version"` pointing at the v2
* endpoint. v1 keeps working until 2.0; endpoints without a v2 version get no header yet.
*/
// When these v1 endpoints got their v2 replacement (1.51.3's release day).
const DEPRECATED_AT = Math.floor(Date.UTC(2026, 9, 29) / 1000);
type Successor = { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string };
const SUCCESSORS: Successor[] = [
{ method: 'GET', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
// 1.51.7: setting events up
{ method: 'GET', v1: /^\/api\/events\/all$/, v2: () => '/api/v2/events/manage' },
{ method: 'POST', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
{ method: 'DELETE', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
{ method: 'GET', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'PUT', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
{ method: 'POST', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/auth/signup' },
{ method: 'POST', v1: /^\/api\/users\/login$/, v2: () => '/api/v2/auth/login' },
{ method: 'POST', v1: /^\/api\/users\/login\/2fa$/, v2: () => '/api/v2/auth/login/code' },
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations' },
{ method: 'POST', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/cancel` },
{ method: 'POST', v1: /^\/api\/payments\/checkout$/, v2: () => '/api/v2/registrations/{id}/checkout' },
{ method: 'GET', v1: /^\/api\/tickets\/scan-preview\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
{ method: 'POST', v1: /^\/api\/tickets\/scan\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
];
// GET /api/events/all isn't a single event.
const NOT_AN_EVENT_ID = new Set(['all']);
/** The v2 successor of a v1 request, if it has one. Pure; exported for tests. */
export function successorOf(method: string, path: string): string | null {
for (const s of SUCCESSORS) {
if (s.method !== method) continue;
const m = path.match(s.v1);
if (!m) continue;
if (s.v1.source.startsWith('^\\/api\\/events\\/(') && NOT_AN_EVENT_ID.has(m[1])) continue;
return s.v2(m);
}
return null;
}
export const deprecationHeaders: RequestHandler = (req, res, next) => {
const successor = successorOf(req.method, req.originalUrl.split('?')[0]);
if (successor) {
res.setHeader('Deprecation', `@${DEPRECATED_AT}`);
res.setHeader('Link', `<${successor}>; rel="successor-version"`);
}
next();
};
+24 -1
View File
@@ -23,7 +23,7 @@ export const ERROR_STATUS: Record<ErrorCode, number> = {
NOT_FOUND: 404,
CONFLICT: 409,
RATE_LIMITED: 429,
READ_ONLY: 423,
READ_ONLY: 403,
INTERNAL: 500,
};
@@ -80,3 +80,26 @@ export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
};
export const notFoundHandler: RequestHandler = (_req, _res, next) => next(new ApiError('NOT_FOUND', 'There is no such API endpoint.'));
// v1-style codes from shared middleware, kept as the v2 error's `reason`.
const READ_ONLY_REASONS = new Set(['IMPERSONATION_READ_ONLY', 'LICENCE_READ_ONLY']);
/**
* Mounted in front of everything on /api/v2: middleware shared with v1 (rate limits, the
* read-only guards, the auth checks, campus refusals) answers `{ message, code? }`; on v2 that
* becomes `{ error: { code, message, reason? } }`, where `reason` is the specific old code
* (e.g. TWO_FACTOR_REQUIRED, LICENCE_READ_ONLY, OTHER_CAMPUS).
*/
export const legacyErrorsToV2: RequestHandler = (_req, res, next) => {
const json = res.json.bind(res);
res.json = (body?: unknown) => {
const b = body as { message?: unknown; code?: unknown; error?: unknown } | null;
if (res.statusCode >= 400 && b && typeof b === 'object' && !('error' in b) && typeof b.message === 'string') {
const reason = typeof b.code === 'string' ? b.code : undefined;
const code: ErrorCode = reason && READ_ONLY_REASONS.has(reason) ? 'READ_ONLY' : STATUS_CODE[res.statusCode] || (res.statusCode >= 500 ? 'INTERNAL' : 'VALIDATION_FAILED');
return json({ error: { code, message: b.message, ...(reason ? { reason } : {}) } });
}
return json(body);
};
next();
};
+2
View File
@@ -5,6 +5,8 @@ import { buildOpenApi } from './openapi';
import { errorHandler, notFoundHandler } from './errors';
import auth from '../middleware/authMiddleware';
// Resources: importing one registers its routes.
// eventsManage first: /events/manage must match before /events/:id.
import './resources/eventsManage';
import './resources/events';
import './resources/me';
import './resources/registrations';
+1
View File
@@ -34,6 +34,7 @@ const errorSchema = {
code: { type: 'string', enum: Object.keys(ERROR_STATUS) },
message: { type: 'string' },
fields: { type: 'object', additionalProperties: { type: 'string' }, description: 'Per-field problems (VALIDATION_FAILED only)' },
reason: { type: 'string', description: 'A more specific cause when there is one, e.g. TWO_FACTOR_REQUIRED, IMPERSONATION_ENDED, LICENCE_READ_ONLY, OTHER_CAMPUS' },
},
},
},
+5 -2
View File
@@ -54,9 +54,11 @@ export const preview = defineRoute({
id,
eventId: id,
eventTitle: z.string().nullable(),
ticketOptionId: id.nullable().describe('For section checks'),
ticketName: z.string().nullable(),
variant: z.string().nullable(),
holder: z.string().nullable().describe('The attendee named on it, or the person who registered'),
attendee: z.string().nullable().describe('The attendee named on it, if any'),
bookedBy: z.string().nullable().describe('Who registered'),
quantity: z.number().int(),
remaining: z.number().int().describe('Still to check in (this session, on multi-day events)'),
session: sessionRef.nullable(),
@@ -75,7 +77,8 @@ export const preview = defineRoute({
message: null,
ticket: {
id: t.id, eventId: t.eventId, eventTitle: t.event.title ?? null, ticketName: t.registrationOption.eventOption.name ?? null,
variant: t.registrationOption.variant?.name ?? null, holder: t.attendee?.name ?? t.user.name ?? null, quantity: t.quantity,
ticketOptionId: t.registrationOption.eventOption.id ?? null, variant: t.registrationOption.variant?.name ?? null,
attendee: t.attendee?.name ?? null, bookedBy: t.user.name ?? null, quantity: t.quantity,
remaining: t.remaining, session: t.session, sessionError: t.sessionError, consentStatus: t.consentStatus,
},
},
+190
View File
@@ -0,0 +1,190 @@
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute, type ApiUser } from '../route';
import { single } from '../paging';
import { absoluteUrl, id, idParams, isoDate } from '../schemas';
import {
createEvent, updateEvent, deactivateEvent, listManagedEvents, getNotifyRecipients, setNotifyRecipients, copySetupFrom,
} from '../../services/events';
/* eslint-disable @typescript-eslint/no-explicit-any -- event records are passed through as stored */
const campusScope = require('../../utils/campusScope') as {
scopeFor(req: unknown): Promise<object | null>;
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown }): any;
scopeBody(targets: [string, string][], opts?: object): any;
};
/**
* Staff and supervisor views of events (1.51.7). Event records are large (ticket options with
* tiers and variants, the attendee form, sessions…) and grow with features, so the schema types
* the core fields and lets the rest through as stored (`looseObject`); dates come back as ISO
* strings. Registered before resources/events.ts so /events/manage isn't read as an event id.
*/
const option = z.looseObject({ id, name: z.string(), price: z.number(), isMainTicket: z.boolean(), stockLimit: z.number().int() });
const session = z.looseObject({ id, label: z.string().nullable(), startsAt: isoDate, endsAt: isoDate, cancelled: z.boolean() });
export const managedEvent = z.looseObject({
id,
title: z.string(),
description: z.string().nullable(),
startDate: isoDate,
endDate: isoDate,
isActive: z.boolean(),
isHidden: z.boolean(),
requiresRegistration: z.boolean(),
scheduleType: z.enum(['single', 'sessions']),
attendanceMode: z.enum(['whole', 'pick']),
cashupStatus: z.enum(['open', 'closed']),
campusId: id.nullable(),
seriesId: id.nullable(),
picture: z.string().nullable(),
eventOptions: z.array(option).optional(),
sessions: z.array(session).optional(),
});
/** A stored record as JSON (dates → ISO strings), with the picture as an absolute URL. */
const toJson = (req: Request, ev: any) => {
const plain = JSON.parse(JSON.stringify(ev));
if ('picture' in plain) plain.picture = absoluteUrl(req, plain.picture);
return plain;
};
const ctxOf = async (req: Request) => {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
return { scope: await campusScope.scopeFor(req), ip: getClientIp(req) };
};
const actorOf = (user: ApiUser | null) => ({ id: user!.id, role: user!.role });
// The event wizard's fields. Only these are named here; the rest of the wizard's fields (consent,
// waitlist, sponsorship, sessions, late joining, certificates, form…) pass through as sent.
const eventBody = z.looseObject({
title: z.string().trim().min(1).max(200).optional(),
description: z.string().max(20000).nullable().optional(),
startDate: z.string().max(40).optional(),
endDate: z.string().max(40).optional(),
price: z.union([z.number(), z.string().max(20)]).optional(),
campusId: id.nullable().optional(),
seriesId: id.nullable().optional(),
scheduleType: z.enum(['single', 'sessions']).optional(),
sessions: z.array(z.looseObject({})).max(200).optional(),
});
export const listManaged = defineRoute({
method: 'get',
path: '/events/manage',
tag: 'Events (staff)',
summary: 'Every event staff can see',
description: 'Hidden ones too. Upcoming by default; `past`, `inactive` and `closed` (cashed-up) widen it. Campus-scoped staff see their campuses\' events and church-wide ones. Not paged — it\'s what the event pickers search.',
access: 'staff',
campusScope: 'scopeList (in listManagedEvents)',
query: z.object({
past: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include events that have ended'),
inactive: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include deactivated events'),
closed: z.enum(['true', 'false']).default('true').transform(v => v === 'true').describe('Include cashed-up events (default true)'),
campus: z.string().max(64).optional().describe("A campus id or slug, or 'all' for church-wide events only"),
}),
response: z.object({ data: z.array(managedEvent) }),
async handler({ query, req }) {
const events = await listManagedEvents({ includePast: query.past, includeInactive: query.inactive, excludeClosed: !query.closed, campus: query.campus }, await ctxOf(req));
return { data: events.map(ev => toJson(req, ev)) };
},
});
export const create = defineRoute({
method: 'post',
path: '/events',
tag: 'Events (staff)',
summary: 'Create an event',
description: 'Gets a "Main Ticket" at the event price (unless registration is off). A campus-scoped supervisor must choose one of their campuses.',
access: 'supervisor',
campusScope: 'campus checked in createEvent (assertCampusAllowed)',
status: 201,
body: eventBody.extend({ title: z.string().trim().min(1).max(200), startDate: z.string().max(40), endDate: z.string().max(40) }),
response: single(managedEvent),
async handler({ body, user, req }) {
return { data: toJson(req, await createEvent(body, actorOf(user), await ctxOf(req))) };
},
});
export const update = defineRoute({
method: 'patch',
path: '/events/:id',
tag: 'Events (staff)',
summary: 'Change an event',
description: 'Only the fields sent change. Sessions save with the event (a refused schedule change saves nothing). A cashed-up event can\'t change until an admin reopens it. The answer includes `sessionChanges` (added, moved, cancelled… counts) when sessions were sent.',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
params: idParams,
body: eventBody,
response: single(managedEvent.extend({ sessionChanges: z.record(z.string(), z.number()).nullable() })),
async handler({ params, body, user, req }) {
return { data: toJson(req, await updateEvent(params.id, body, actorOf(user), await ctxOf(req))) };
},
});
export const remove = defineRoute({
method: 'delete',
path: '/events/:id',
tag: 'Events (staff)',
summary: 'Deactivate an event',
description: 'Admins only. The event is deactivated, not deleted: registrations, payments and tickets stay.',
access: 'admin',
params: idParams,
response: single(z.object({ id, isActive: z.literal(false) })),
async handler({ params, user, req }) {
const ev = await deactivateEvent(params.id, actorOf(user), await ctxOf(req));
return { data: { id: ev.id, isActive: false as const } };
},
});
export const copyFrom = defineRoute({
method: 'post',
path: '/events/:id/copy-from',
tag: 'Events (staff)',
summary: 'Copy set-up from another event',
description: 'Allocation groups, and planned costs for admins — used right after copying an event.',
access: 'supervisor',
campusScope: 'scopeEvent(manage) + scopeBody(fromEventId)',
middleware: [campusScope.scopeEvent({ need: 'manage' }), campusScope.scopeBody([['fromEventId', 'event']])],
params: idParams,
body: z.object({ fromEventId: id }),
response: single(z.looseObject({})),
async handler({ params, body, user }) {
return { data: JSON.parse(JSON.stringify(await copySetupFrom(params.id, body.fromEventId, actorOf(user)) ?? {})) };
},
});
const recipient = z.object({ id, name: z.string(), email: z.string(), role: z.enum(['admin', 'supervisor', 'staff', 'user']) });
export const listRecipients = defineRoute({
method: 'get',
path: '/events/:id/notify-recipients',
tag: 'Events (staff)',
summary: 'Who gets this event\'s emails',
description: 'Registration, payment and daily-summary emails.',
access: 'supervisor',
campusScope: 'scopeEvent(view)',
middleware: [campusScope.scopeEvent()],
params: idParams,
response: z.object({ data: z.array(recipient) }),
async handler({ params }) {
return { data: await getNotifyRecipients(params.id) };
},
});
export const setRecipients = defineRoute({
method: 'put',
path: '/events/:id/notify-recipients',
tag: 'Events (staff)',
summary: 'Set who gets this event\'s emails',
access: 'supervisor',
campusScope: 'scopeEvent(manage)',
middleware: [campusScope.scopeEvent({ need: 'manage' })],
params: idParams,
body: z.object({ userIds: z.array(id).max(200) }),
response: z.object({ data: z.array(recipient) }),
async handler({ params, body }) {
return { data: await setNotifyRecipients(params.id, body.userIds) };
},
});
+97 -3
View File
@@ -25,7 +25,7 @@ jest.mock('../src/middleware/authMiddleware', () => {
optionalAuth: (req: { user?: unknown }, _res: unknown, next: () => void) => { if (signedIn) req.user = signedIn; next(); },
admin: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (req.user?.role === 'admin' ? next() : deny(403, 'Not authorized as an admin')(req, res, next as (e: Error) => void)),
staff: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (['admin', 'supervisor', 'staff'].includes(req.user?.role || '') ? next() : deny(403, 'Not authorized as staff')(req, res, next as (e: Error) => void)),
supervisor: jest.fn(),
supervisor: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (['admin', 'supervisor'].includes(req.user?.role || '') ? next() : deny(403, 'Not authorized as a supervisor')(req, res, next as (e: Error) => void)),
loginLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
};
});
@@ -41,12 +41,17 @@ jest.mock('../src/services/auth', () => ({
}));
const mockCheckin = { previewScan: jest.fn(), scanTicket: jest.fn() };
jest.mock('../src/services/checkin', () => ({ previewScan: (...a: unknown[]) => mockCheckin.previewScan(...a), scanTicket: (...a: unknown[]) => mockCheckin.scanTicket(...a) }));
const mockScope = { scope: null as object | null, campusId: 'north' as string | null };
const mockScope = { scope: null as object | null, campusId: 'north' as string | null, blockEvent: false };
jest.mock('../src/utils/campusScope', () => ({
scopeFor: async () => mockScope.scope,
eventOf: async () => ({ campusId: mockScope.campusId, campusName: 'North' }),
canView: (scope: { campusIds: string[] }, campusId: string | null) => !campusId || scope.campusIds.includes(campusId),
// Route-level checks: refuse like v1 does (v1 shape — the v2 wrapper isn't in this test app).
scopeEvent: () => (_req: unknown, res: { status(n: number): { json(b: unknown): void } }, next: () => void) => (mockScope.blockEvent ? res.status(404).json({ message: 'Not found' }) : next()),
scopeBody: () => (_req: unknown, _res: unknown, next: () => void) => next(),
}));
const mockEvents = { listManagedEvents: jest.fn(), createEvent: jest.fn(), updateEvent: jest.fn(), deactivateEvent: jest.fn(), getNotifyRecipients: jest.fn(), setNotifyRecipients: jest.fn(), copySetupFrom: jest.fn() };
jest.mock('../src/services/events', () => Object.fromEntries(Object.keys(mockEvents).map(k => [k, (...a: unknown[]) => (mockEvents as Record<string, jest.Mock>)[k](...a)])));
import { v2Router } from '../src/v2';
import { ServiceError } from '../src/services/errors';
@@ -333,6 +338,95 @@ describe('check-in', () => {
signedIn = { id: 's1', role: 'staff' };
mockCheckin.previewScan.mockResolvedValue({ outcome: 'found', ticket: { ...ticket, consentStatus: 'not_required', remaining: 2, session: null, sessionError: null, registrationOption: { id: 'ro1', eventOption: { id: 'o1', name: 'Camper' }, variant: null }, event: { id: 'e1', title: 'Youth Camp' }, user: { id: 'u1', name: 'Thandi' }, attendee: { name: 'Lulu' } } });
const res = await request(app).get('/api/v2/checkin/QR1');
expect(res.body.data.ticket).toMatchObject({ holder: 'Lulu', remaining: 2, consentStatus: 'not_required' });
expect(res.body.data.ticket).toMatchObject({ attendee: 'Lulu', bookedBy: 'Thandi', ticketOptionId: 'o1', remaining: 2, consentStatus: 'not_required' });
});
});
describe('v1 deprecation headers', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { successorOf } = require('../src/v2/deprecation');
test('v1 endpoints with a v2 replacement point at it; the rest say nothing', () => {
expect(successorOf('GET', '/api/events')).toBe('/api/v2/events');
expect(successorOf('GET', '/api/events/e1')).toBe('/api/v2/events/e1');
expect(successorOf('GET', '/api/events/all')).toBe('/api/v2/events/manage');
expect(successorOf('PUT', '/api/events/e1')).toBe('/api/v2/events/e1');
expect(successorOf('DELETE', '/api/registrations/r1')).toBe('/api/v2/registrations/r1/cancel');
expect(successorOf('POST', '/api/tickets/scan/QR1')).toBe('/api/v2/checkin/QR1');
expect(successorOf('GET', '/api/reports/summary')).toBeNull();
expect(successorOf('GET', '/api/v2/events')).toBeNull();
});
});
describe('shared middleware on v2', () => {
test("v1-style refusals become v2 errors, keeping the specific code as reason", async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { legacyErrorsToV2 } = require('../src/v2/errors');
const mini = express();
mini.use('/api/v2', legacyErrorsToV2);
mini.get('/api/v2/limited', (_req, res) => { res.status(429).json({ message: 'Too many requests, please try again later.' }); });
mini.post('/api/v2/locked', (_req, res) => { res.status(403).json({ code: 'LICENCE_READ_ONLY', message: 'Read only.' }); });
mini.get('/api/v2/twofa', (_req, res) => { res.status(401).json({ code: 'TWO_FACTOR_REQUIRED', message: 'Set up 2FA.' }); });
mini.get('/api/v2/fine', (_req, res) => { res.json({ data: { message: 'not an error' } }); });
expect((await request(mini).get('/api/v2/limited')).body).toEqual({ error: { code: 'RATE_LIMITED', message: 'Too many requests, please try again later.' } });
expect((await request(mini).post('/api/v2/locked')).body).toEqual({ error: { code: 'READ_ONLY', message: 'Read only.', reason: 'LICENCE_READ_ONLY' } });
expect((await request(mini).get('/api/v2/twofa')).body.error).toMatchObject({ code: 'UNAUTHENTICATED', reason: 'TWO_FACTOR_REQUIRED' });
expect((await request(mini).get('/api/v2/fine')).body).toEqual({ data: { message: 'not an error' } });
});
});
describe('events (staff)', () => {
const stored = (over: Record<string, unknown> = {}) => ({
id: 'e1', title: 'Camp', description: null, startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z'),
isActive: true, isHidden: true, requiresRegistration: true, scheduleType: 'single', attendanceMode: 'whole', cashupStatus: 'open',
campusId: null, seriesId: null, picture: '/uploads/events/c.jpg', waitlistEnabled: true,
eventOptions: [{ id: 'o1', name: 'Main Ticket', price: 600, isMainTicket: true, stockLimit: 0, earlyBirdTiers: [] }], sessions: [], ...over,
});
afterEach(() => { mockScope.blockEvent = false; });
test('/events/manage lists hidden events for staff, with the rest of each record passed through', async () => {
signedIn = { id: 's1', role: 'staff' };
mockEvents.listManagedEvents.mockResolvedValue([stored()]);
const res = await request(app).get('/api/v2/events/manage?past=true');
expect(res.status).toBe(200);
expect(res.body.data[0]).toMatchObject({ id: 'e1', isHidden: true, waitlistEnabled: true, startDate: '2026-12-04T16:00:00.000Z' });
expect(res.body.data[0].picture).toMatch(/^http:\/\/.*\/uploads\/events\/c\.jpg$/);
expect(mockEvents.listManagedEvents).toHaveBeenCalledWith({ includePast: true, includeInactive: false, excludeClosed: false, campus: undefined }, expect.objectContaining({ scope: null }));
});
test('members can\'t list or create', async () => {
signedIn = { id: 'u1', role: 'user' };
expect((await request(app).get('/api/v2/events/manage')).body.error.code).toBe('FORBIDDEN');
signedIn = { id: 's1', role: 'staff' };
expect((await request(app).post('/api/v2/events').send({ title: 'X', startDate: '2026-12-01', endDate: '2026-12-02' })).body.error.code).toBe('FORBIDDEN');
});
test('create needs a title and dates; update passes only what was sent', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
const bad = await request(app).post('/api/v2/events').send({ title: '' });
expect(Object.keys(bad.body.error.fields).sort()).toEqual(['endDate', 'startDate', 'title']);
mockEvents.createEvent.mockResolvedValue(stored());
const made = await request(app).post('/api/v2/events').send({ title: 'Camp', startDate: '2026-12-04T16:00', endDate: '2026-12-06T12:00', waitlistEnabled: true });
expect(made.status).toBe(201);
expect(mockEvents.createEvent.mock.calls[0][0]).toMatchObject({ title: 'Camp', waitlistEnabled: true });
mockEvents.updateEvent.mockResolvedValue({ ...stored({ title: 'New' }), sessionChanges: null });
const upd = await request(app).patch('/api/v2/events/e1').send({ title: 'New' });
expect(upd.body.data).toMatchObject({ title: 'New', sessionChanges: null });
expect(mockEvents.updateEvent).toHaveBeenCalledWith('e1', { title: 'New' }, { id: 'v1', role: 'supervisor' }, expect.any(Object));
});
test("a campus-scoped supervisor can't change another campus's event", async () => {
signedIn = { id: 'v1', role: 'supervisor' };
mockScope.blockEvent = true;
expect((await request(app).patch('/api/v2/events/e1').send({ title: 'X' })).status).toBe(404);
expect(mockEvents.updateEvent).not.toHaveBeenCalled();
});
test('deactivating is for admins', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
expect((await request(app).delete('/api/v2/events/e1')).status).toBe(403);
signedIn = { id: 'a1', role: 'admin' };
mockEvents.deactivateEvent.mockResolvedValue({ id: 'e1', isActive: false });
expect((await request(app).delete('/api/v2/events/e1')).body.data).toEqual({ id: 'e1', isActive: false });
});
});
+54 -9
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-29, after **1.51.2** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-29, after **1.51.7** (and Hub 1.5.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,8 +14,11 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
| 1.51.7 | **Setting events up on v2** (`/events/manage`, create, `PATCH`, deactivate, copy-from, notify-recipients) via `src/services/events.ts`; `fetchManagedEvents()` for every picker; **pg-boss 11** in schema `pgboss_v11` |
| 1.51.6 | Command palette lists every report (from `ReportCatalog`) and every dashboard page (e2e drift check); exceljs → ExcelTS 10 (`@cj-tech-master/excelts`, no dependencies; e2e `e2e/excel.spec.ts`); backend `engines.node` >= 22 |
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
@@ -64,9 +67,14 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.51.3 — the rest of v2** (the scanner page onto `/checkin`, staff and admin screens),
**Express 5, v1 deprecation headers and v2-shaped global middleware** (rate limits, read-only
modes, campus refusals), then **2.0.0 — AI helpers** (§15).
1. **1.51.8 – 1.51.14: every endpoint onto v2**, one area per release (roadmap build order, row
11b): services in TS, screens on `apiV2`, v1 routes into `SUCCESSORS`. 1.51.14 gives incoming
webhooks and links in sent messages v2 URLs, with the v1 paths still answering (links redirect,
webhooks handled by the same code). Then **2.0.0** — AI helpers and v1 removed.
2. **Servers run Node 24** (Joshua, 2026-10-29), so the backend may need Node 22+. Still to do:
move from ExcelTS — deprecated in favour of `documonster` by the same author (pre-1.0 in Oct
2026) — once documonster is stable. After 1.51.7 has run everywhere, the old `pgboss` schema
can be dropped (`DROP SCHEMA pgboss CASCADE;`).
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the
licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub
@@ -82,7 +90,7 @@ a test server (both done 2026-10-28).
or serious fix) → `git merge --no-ff`
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
version per feature.
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (since Next 16 `next build` no longer lints — it still fails on TypeScript errors — so lint errors only show up if you run it; keep it at 0 errors). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language. CHANGELOG.md is the
full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from main every
15 minutes, for tagged versions only and sets the latest version from the tags — nothing to do in the Hub).
@@ -94,8 +102,10 @@ a test server (both done 2026-10-28).
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).
- **Server Node version:** Node 24 LTS on every server (2026-10-29); new installs get it too.
The backend declares `engines.node >= 22`. pg-boss 11 (1.51.7) runs in its own schema,
`pgboss_v11` — it can't migrate pg-boss 10's `pgboss` schema; there's no `archive` table any more
(finished runs stay in `job` until they expire).
- **Deploying:** `bash scripts/deploy.sh [tag]` on the server (no tag = latest main). Each install
keeps its own `ecosystem.config.js` (PM2 app names, port), which is not in git. The script
runs from a temp copy of itself and rolls back on failure before the PM2 reload. It was
@@ -455,6 +465,36 @@ a test server (both done 2026-10-28).
`fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search
picker would replace them; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array).
## Setting events up on v2 (1.51.7)
- **Staff event lists:** use `fetchManagedEvents(token, { past, inactive, closed, campus })`
(`lib/api.ts`, v2 `GET /events/manage`), never `/api/events/all`.
- **Loose records:** staff endpoints that return whole stored records (events now; more to come)
type the core fields and pass the rest through (`z.looseObject`), serialised with
`JSON.parse(JSON.stringify(...))` so dates are ISO strings. Public and member endpoints stay strict.
- **Campus scope on v2:** reuse the v1 middleware with `middleware: [scopeEvent({ need: 'manage' })]`
— its `{ message }` refusals become v2 errors through `legacyErrorsToV2`.
- **Route order:** fixed paths (`/events/manage`) must be defined before `/events/:id` —
`eventsManage.ts` is imported before `events.ts` in `src/v2/index.ts`.
## Command palette and Excel (1.51.6)
- **Palette:** reports come from `components/reports/ReportCatalog.ts` — add a report there and it
appears. A new dashboard page needs a line in `lib/commandPages.ts`, or an entry in
`NOT_IN_PALETTE` in `e2e/dashboard.spec.ts` (which fails on any page that's neither).
- **Excel:** `require('@cj-tech-master/excelts')` — the exceljs API (`new ExcelJS.Workbook()`,
`xlsx.write(res)`). Reports need `kind: 'table'` (or `'layered'`) for the main table.
## Next.js 16 (1.51.5)
- Turbopack for dev and build (the default). Lint with `npm run lint` / `npx eslint src` (`next lint`
is gone). There's no middleware/proxy file — add `src/proxy.ts` (Next 16's name) only if needed.
- **React Compiler lint rules are warnings, not errors** (`eslint.config.mjs`): 181 existing places,
mostly `useEffect(() => { load(); }, [...])`. Fix them in a screen when it's moved onto v2
(1.51.6 – 1.51.11), then turn the rules back to errors. Don't add new ones.
- `eslint-config-next` 16's bundled plugins (react, import, jsx-a11y) don't list ESLint 10 as a peer
yet; they work, and npm notes the override. Revisit when they publish ESLint 10 support.
## TypeScript and API v2 (1.51.0)
- **Starting the backend:** PM2 runs `src/index.js`, a launcher. With plain Node it runs
@@ -485,8 +525,13 @@ a test server (both done 2026-10-28).
- **Frontend v2 calls:** `apiV2("post", "/registrations/{id}/cancel", { params: { id }, authToken })`
(`frontend/src/lib/apiV2.ts`). After adding or changing a v2 route run `npm run api:types` in the
repo root and commit `frontend/src/lib/api-v2/` (`tests/v2.test.ts` fails while it's stale).
- Not yet v2-shaped: the global middleware in front of every route (rate limit, impersonation
read-only guard, licence read-only) still answers `{ message }`. Do that with 1.51.2.
- **Shared middleware on v2** (1.51.3): `legacyErrorsToV2` (mounted on `/api/v2` before everything)
rewrites any `{ message, code }` error from middleware shared with v1 into the v2 shape, with the
old code as `reason`. New middleware can keep writing `{ message, code }`.
- **Deprecating v1:** when a v1 endpoint gets a v2 replacement, add it to `SUCCESSORS` in
`src/v2/deprecation.ts`.
- **Express 5:** route paths use path-to-regexp v8 (no `*`, `?` or regex in paths — use `{/:x}` for
optional parts, `/*name` for wildcards); rejected promises in handlers reach the error handler.
## Event picker and webhook guide (1.50.2)
+8 -4
View File
@@ -39,8 +39,10 @@ report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
foundations**, 1.51.1 **v2 registrations and payments** and 1.51.2 **v2 accounts and check-in** shipped.
**Next: 1.51.3 — the rest of v2, Express 5.**
foundations**, 1.51.1 **v2 registrations and payments**, 1.51.2 **v2 accounts and check-in** and 1.51.3 **Express 5,
scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependency updates**, 1.51.5 **Next.js 16** and 1.51.6 **the complete command palette and
ExcelTS** and 1.51.7 **setting events up on v2, pg-boss 11** shipped. **Next: 1.51.8 – 1.51.14 (every
endpoint onto v2), then 2.0.0.**
**Repositories:**
@@ -1008,8 +1010,9 @@ income justify the hardware:
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 the rest, Express 5, v1 deprecation headers | The breaking-change groundwork |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
| 11b | 1.51.4 ✅, 1.51.5 ✅, 1.51.6 ✅, 1.51.7 ✅, 1.51.8 – 1.51.14 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of the events area (event details for staff, ticket options and variants, attachments, sections, costs, uploads, series, season passes, bundles, coupons, campuses, calendar file, alias); 1.51.9 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.10 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.11 kids check-in and volunteer rosters; 1.51.12 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.13 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.14 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each moves its area's logic into TS services, its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`),
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
@@ -1046,6 +1049,7 @@ feature.
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
| — | v1 in 2.0 | **Every endpoint moves to v2 before 2.0** (1.51.7 – 1.51.14, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Incoming webhooks (Yoco, PayFast, WAWP) and links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) also get v2 URLs (1.51.14); their v1 paths keep working — links redirect (308) to v2, provider webhooks are handled by the same code — because old messages and provider registrations still use them (2026-10-29) |
| — | AI buttons and markup | AI buttons show only when the licence's monthly cap is **above R0**. When the cap is reached they stay visible but **disabled** until next month or until the cap is raised. Usage is charged at the real cost **plus a markup %** set in the Hub (a default, plus an override per licence). The markup covers small overruns and is the profit. The cap is counted in charged rand, and each call is checked against its worst-case cost before it's sent (2026-10-29) |
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
+33 -1
View File
@@ -84,13 +84,45 @@ test.describe("API v2", () => {
expect((await (await request.post(`${API}/api/v2/checkin/NOT-A-CODE`, { headers: staff, data: {} })).json()).error.code).toBe("NOT_FOUND");
});
test("v1 endpoints with a v2 version say so", async ({ request }) => {
const v1 = await request.get(`${API}/api/events`);
expect(v1.headers()["deprecation"]).toMatch(/^@\d+$/);
expect(v1.headers()["link"]).toBe(`</api/v2/events>; rel="successor-version"`);
const all = await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
expect(all.headers()["link"]).toBe(`</api/v2/events/manage>; rel="successor-version"`);
// Endpoints with no v2 version yet say nothing.
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["deprecation"]).toBeUndefined();
});
test("staff list every event; supervisors create, change and admins deactivate", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const list = await (await request.get(`${API}/api/v2/events/manage`, { headers: staff })).json();
// The kids event is hidden from the public list but staff see it.
expect(list.data.map((e: { id: string }) => e.id)).toContain(seed().events.kids);
expect((await (await request.get(`${API}/api/v2/events?limit=100`)).json()).data.map((e: { id: string }) => e.id)).not.toContain(seed().events.kids);
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const start = new Date(Date.now() + 20 * 864e5);
const made = await request.post(`${API}/api/v2/events`, { headers: sup, data: { title: "E2E v2 Event", description: "Made through v2", price: 0, location: "1 Test Street", startDate: start.toISOString(), endDate: new Date(start.getTime() + 3600e3).toISOString() } });
expect(made.status()).toBe(201);
const ev = (await made.json()).data;
expect(ev.eventOptions.map((o: { name: string }) => o.name)).toEqual(["Main Ticket"]);
const changed = await (await request.patch(`${API}/api/v2/events/${ev.id}`, { headers: sup, data: { title: "E2E v2 Event (renamed)", waitlistEnabled: true } })).json();
expect(changed.data).toMatchObject({ title: "E2E v2 Event (renamed)", waitlistEnabled: true, sessionChanges: null });
expect((await request.delete(`${API}/api/v2/events/${ev.id}`, { headers: sup })).status()).toBe(403);
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
expect((await (await request.delete(`${API}/api/v2/events/${ev.id}`, { headers: admin })).json()).data).toEqual({ id: ev.id, isActive: false });
});
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
await page.goto(`${API}/docs/v2?token=${seed().users.admin.token}`);
await expect(page.getByRole("heading", { name: /Cross Events API/ })).toBeVisible();
await expect(page.locator(".opblock-summary-path", { hasText: "/events/{id}" })).toBeVisible();
await expect(page.getByRole("button", { name: /^GET \/events\/\{id\} One public/ })).toBeVisible();
// The token isn't left in the address bar.
expect(page.url()).not.toContain("token=");
});
+41
View File
@@ -1,5 +1,18 @@
import fs from "fs";
import path from "path";
import { test, expect, loginAs } from "./fixtures";
// Dashboard pages the palette leaves out on purpose: role home pages it reaches through
// "Dashboard", pages that only redirect or reuse another, and an older form nothing links to.
const NOT_IN_PALETTE = new Set([
"/dashboard/admin", "/dashboard/staff", "/dashboard/supervisor", // role homes ("Dashboard")
"/dashboard/admin/settings", // its tabs are listed one by one
"/dashboard/admin/whatsapp", "/dashboard/supervisor/sections", "/dashboard/admin/forms", // redirects / the same page
"/dashboard/admin/cashup/[id]", // one event's cashup, from Cashup
"/dashboard/supervisor/manual-registration", // the older manual form
"/dashboard/user/reset-password", // from My profile & security
]);
// Dashboard "Today" and "Waiting for you", and the Ctrl+K command palette (1.49.0).
test.describe("dashboard and command palette", () => {
test("the admin dashboard shows what's on today and what's waiting", async ({ page }) => {
@@ -39,6 +52,8 @@ test.describe("dashboard and command palette", () => {
// Staff don't get admin pages.
await loginAs(page, "staff");
await page.goto("/dashboard/staff");
// The shortcut listens once the signed-in user has loaded (the search button shows then).
await expect(page.getByRole("button", { name: /Search or jump to/ })).toBeVisible();
await page.keyboard.press("Control+k");
const staffDialog = page.getByRole("dialog", { name: "Go to or search" });
await staffDialog.getByRole("combobox", { name: "Search" }).fill("users");
@@ -46,4 +61,30 @@ test.describe("dashboard and command palette", () => {
await staffDialog.getByRole("combobox", { name: "Search" }).fill("scan");
await expect(staffDialog.getByRole("option", { name: "Scan tickets" })).toBeVisible();
});
test("the palette lists every report and every dashboard page", async ({ page }) => {
const root = path.join(__dirname, "..", "frontend", "src");
const palette = fs.readFileSync(path.join(root, "lib", "commandPages.ts"), "utf8");
const pages: string[] = [];
const walk = (dir: string) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) walk(path.join(dir, e.name));
else if (e.name === "page.tsx") pages.push("/" + path.relative(path.join(root, "app"), dir).split(path.sep).join("/"));
}
};
walk(path.join(root, "app", "dashboard"));
const missing = pages.filter(p => !NOT_IN_PALETTE.has(p) && !palette.includes(`href: "${p}"`) && !palette.includes(`href: "${p}?`));
expect(missing).toEqual([]);
// Reports come from the catalogue: one the old hand-made list didn't have.
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor");
await expect(page.getByRole("button", { name: /Search or jump to/ })).toBeVisible();
await page.keyboard.press("Control+k");
const dialog = page.getByRole("dialog", { name: "Go to or search" });
await dialog.getByRole("combobox", { name: "Search" }).fill("medical");
await expect(dialog.getByRole("option", { name: /Report: Medical & emergency list/ })).toBeVisible();
await dialog.getByRole("combobox", { name: "Search" }).fill("profit");
await expect(dialog.getByRole("option", { name: /Report: Profit report/ })).toBeVisible();
});
});
+48
View File
@@ -0,0 +1,48 @@
import path from "path";
import { test, expect, seed, API } from "./fixtures";
// Excel exports (ExcelTS since 1.51.6): a styled report and the attendance register come back as
// real workbooks that open and hold what was asked for.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Workbook } = require(path.join(__dirname, "..", "backend", "node_modules", "@cj-tech-master", "excelts"));
const XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
async function open(body: Buffer) {
const wb = new Workbook();
await wb.xlsx.load(body);
return wb;
}
test.describe("Excel exports", () => {
test("a report with stats, a chart and a table", async ({ request }) => {
const res = await request.post(`${API}/api/reports/excel`, {
headers: { Authorization: `Bearer ${seed().users.supervisor.token}` },
data: {
title: "E2E Payments",
kind: "table",
subtitle: "1–31 October",
stats: [{ label: "Total", value: "R 1 200.00" }],
chart: { title: "By method", data: [{ label: "Card", value: 900 }, { label: "Cash", value: 300 }] },
table: { columns: ["Name", "Amount"], rows: [["Thandi", "R 600.00"], ["Sipho", "R 600.00"]] },
},
});
expect(res.status()).toBe(200);
expect(res.headers()["content-type"]).toContain(XLSX);
const ws = (await open(await res.body())).worksheets[0];
const values: string[] = [];
ws.eachRow((row: { values: unknown[] }) => values.push(...row.values.filter(Boolean).map(String)));
expect(values).toEqual(expect.arrayContaining(["E2E Payments", "Total", "Card", "Thandi", "Sipho"]));
});
test("the attendance register", async ({ request }) => {
const res = await request.get(`${API}/api/attendance/${seed().events.course}/export?format=xlsx`, {
headers: { Authorization: `Bearer ${seed().users.supervisor.token}` },
});
expect(res.status()).toBe(200);
expect(res.headers()["content-type"]).toContain(XLSX);
const ws = (await open(await res.body())).getWorksheet("Attendance");
expect(ws.getRow(1).getCell(1).value).toBe("Name");
expect(ws.rowCount).toBeGreaterThan(1);
});
});
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+23 -13
View File
@@ -1,16 +1,26 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
// Next.js 16's own flat configs (no more FlatCompat / eslintrc). Run with `npm run lint`
// (`next lint` was removed in Next 16).
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
// React Compiler-era rules that eslint-plugin-react-hooks 7 (with Next 16) turns on as
// errors. They flag patterns the app uses on purpose and that work (mostly loading data in
// useEffect), so they're warnings while the code is moved off them screen by screen —
// see HANDOVER, "Next.js 16". New code should follow them.
rules: {
"react-hooks/set-state-in-effect": "warn",
"react-hooks/purity": "warn",
"react-hooks/immutability": "warn",
"react-hooks/static-components": "warn",
"react-hooks/refs": "warn",
},
},
globalIgnores([".next/**", ".next-*/**", "out/**", "build/**", "next-env.d.ts", "src/lib/api-v2/schema.d.ts"]),
]);
export default eslintConfig;
+2 -1
View File
@@ -6,7 +6,8 @@ const nextConfig: NextConfig = {
// from clashing with one already running.
distDir: process.env.NEXT_DIST_DIR || ".next",
images: {
domains: ['images.unsplash.com'],
// (images.domains is deprecated in Next 16.)
remotePatterns: [{ protocol: 'https', hostname: 'images.unsplash.com' }],
},
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
+886 -670
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.2",
"version": "1.51.7",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint src",
"api:types": "openapi-typescript src/lib/api-v2/openapi.json -o src/lib/api-v2/schema.d.ts"
},
"dependencies": {
@@ -29,33 +29,33 @@
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@sentry/nextjs": "^10.71.0",
"@sentry/nextjs": "^10.78.0",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"idb": "^8.0.4",
"lucide-react": "^0.536.0",
"next": "^15.5.24",
"next": "^16.4.3",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react": "^19.3.1",
"react-day-picker": "^9.8.1",
"react-dom": "19.1.0",
"react-dom": "^19.3.1",
"react-hook-form": "^7.62.0",
"react-webcam": "^7.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.0.15"
"zod": "^4.6.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/qrcode": "^1.5.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"autoprefixer": "^10.4.21",
"eslint": "^9",
"eslint-config-next": "^15.5.24",
"eslint": "^10.13.0",
"eslint-config-next": "^16.4.3",
"openapi-typescript": "^7.13.0",
"postcss": "^8.5.6",
"tailwindcss": "3.4",
@@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Wallet } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -28,7 +28,7 @@ export default function CashupLandingPage() {
setLoading(true);
setError(null);
try {
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const evs = await fetchManagedEvents<Event>(token, { past: true, inactive: true });
setEvents(Array.isArray(evs) ? evs : []);
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ClipboardList } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -97,11 +97,7 @@ export default function AdminRegistrationsPage() {
const loadEvents = useCallback(async () => {
if (!token) return;
try {
const params = new URLSearchParams();
if (includePastEvents) params.set("includePast", "true");
if (includeInactiveEvents) params.set("includeInactive", "true");
const qs = params.toString() ? `?${params.toString()}` : "";
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
const evs = await fetchManagedEvents(token, { past: includePastEvents, inactive: includeInactiveEvents });
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
} catch {}
}, [token, includePastEvents, includeInactiveEvents]);
@@ -2,7 +2,7 @@
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date";
@@ -41,7 +41,7 @@ function EventTicketsContent() {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
const evs = await fetchManagedEvents<Event>(token, { past: true });
const now = Date.now();
const active = (evs || []).filter((ev) => {
const t = new Date(ev.endDate).getTime();
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { QrCode } from "lucide-react";
import { useOfflineScanning } from "@/components/scanner/useOfflineScanning";
@@ -14,9 +14,10 @@ import type { Event, RecentScan, ScanStats, Section } from "@/types";
import { EventPicker } from "@/components/shared/EventPicker";
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2 } from "@/lib/apiV2";
// GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline
// scans build the same shape from the downloaded ticket list.
// What the confirm step needs, from GET /api/v2/checkin/{qrCode}. Offline scans build the
// same shape from the downloaded ticket list.
type ScanTicket = {
id: string;
eventId?: string;
@@ -87,7 +88,7 @@ export default function TicketScanningPage() {
try {
if (!token) return;
setLoadingEvents(true);
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
const evs = await fetchManagedEvents<Event>(token, { past: true });
const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime());
setAllEvents(sorted);
} catch {
@@ -187,7 +188,21 @@ export default function TicketScanningPage() {
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
let preview: ScanTicket;
try {
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}${sessionParam ? `?sessionId=${encodeURIComponent(sessionParam)}` : ""}`, { authToken: token });
const { data } = await apiV2("get", "/checkin/{qrCode}", { params: { qrCode: text }, query: sessionParam ? { sessionId: sessionParam } : {}, authToken: token });
// A ticket that was handed on: the message says who has it now.
if (data.outcome === "transferred" || !data.ticket) {
setErrorModal(data.message || "This ticket has been transferred.");
return;
}
const t = data.ticket;
preview = {
id: t.id, eventId: t.eventId, eventOptionId: t.ticketOptionId || undefined, quantity: t.quantity, consentStatus: t.consentStatus,
event: { id: t.eventId, title: t.eventTitle || undefined },
registrationOption: { eventOption: { id: t.ticketOptionId || undefined, name: t.ticketName || undefined }, variant: t.variant ? { name: t.variant } : null },
attendee: t.attendee ? { name: t.attendee } : null,
user: { name: t.bookedBy || undefined },
remaining: t.remaining, session: t.session, sessionError: t.sessionError,
};
} catch (pe) {
setErrorModal(errorMessage(pe) || 'Failed to validate ticket');
return;
@@ -280,38 +295,34 @@ export default function TicketScanningPage() {
return;
}
// Background write — reverse the optimistic update on failure
// Background write — reverse the optimistic update unless it checked in
const undo = () => { setSuccessModal(null); setScanInfo(null); };
try {
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
}`;
await apiFetch(url, {
method: "POST",
const { data } = await apiV2("post", "/checkin/{qrCode}", {
params: { qrCode },
authToken: token,
body: {
qty,
people: qty,
deviceId: offline.deviceId(),
...(selectedEventId && selectedEventId !== "all" ? { eventId: selectedEventId } : {}),
...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}),
...(ticket?.session ? { sessionId: ticket.session.id } : {}),
...(overrideConsent ? { overrideConsent: true } : {}),
},
});
refreshStatsAndRecent();
} catch (e) {
setSuccessModal(null);
setScanInfo(null);
const raw = errorMessage(e) || "Failed to scan ticket";
try {
const parsed = JSON.parse(raw);
const msg: string | undefined = parsed?.message;
if (msg && /already\s*(been)?\s*used/i.test(msg)) {
setAlreadyUsedModal({ message: msg, ticket: parsed?.ticket });
return;
}
setErrorModal(msg || raw);
} catch {
setErrorModal(raw);
if (data.outcome === "checked_in") {
refreshStatsAndRecent();
} else if (data.outcome === "already_used") {
// Someone else scanned it in the meantime.
undo();
setAlreadyUsedModal({ message: data.message, ticket });
} else {
undo();
setErrorModal(data.message);
}
} catch (e) {
undo();
setErrorModal(errorMessage(e) || "Failed to scan ticket");
}
}
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { DndContext, DragEndEvent, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -94,7 +94,7 @@ export default function AllocationsPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
fetchManagedEvents<Event>(token, { past: true })
.then(evs => {
const upcoming = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 7 * 864e5)
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
@@ -19,6 +19,7 @@ import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2 } from "@/lib/apiV2";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -131,7 +132,7 @@ export default function AtTheDoorPage() {
if (!token) return;
(async () => {
try {
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const evs = await fetchManagedEvents<Event>(token);
const now = Date.now();
const active = (evs || []).filter(ev => {
@@ -1084,12 +1085,13 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
const qty = qtyFor(ticket);
try {
setSubmittingId(ticket.id);
const res = await apiFetch<{ qtyRedeemed?: number; remaining?: number }>(
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
{ method: "POST", authToken: token, body: { qty } }
);
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
(res?.remaining ?? 0) > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
const { data: res } = await apiV2("post", "/checkin/{qrCode}", {
params: { qrCode: ticket.qrCode }, authToken: token, body: { people: qty, eventId },
});
// Already used, consent missing, wrong session…: say why.
if (res.outcome !== "checked_in") throw new Error(res.message);
setInfo(`${res.checkedIn} checked in for ${registration.user?.name || "guest"}${
(res.remaining ?? 0) > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
}. Confirmation sent.`);
await load();
} catch (e) {
@@ -4,7 +4,7 @@ import React, { Suspense, useCallback, useEffect, useMemo, useState } from "reac
import { useRouter, useSearchParams } from "next/navigation";
import { ClipboardCheck, Check, Download, FileSpreadsheet, FileText, Send, Award, Search, Ban } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { fetchApiFile, saveBlob } from "@/lib/export";
@@ -41,7 +41,7 @@ function AttendancePageInner() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
fetchManagedEvents<Event>(token, { past: true })
.then(evs => {
// Multi-day events first (that's what the register is mostly for), newest first.
const list = (evs || []).filter(e => e.requiresRegistration !== false)
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -101,7 +101,7 @@ export default function BundlesPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token })
fetchManagedEvents<EventLite>(token, { past: true })
.then(list => setAllEvents(list || []))
.catch(e => setError(errorMessage(e) || "Failed to load events"));
}, [token, canView, setError]);
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
@@ -46,7 +46,7 @@ export default function ConsentTrackerPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
fetchManagedEvents<Event>(token, { past: true })
.then(evs => {
const youth = (evs || []).filter(e => e.requiresParentalConsent);
setEvents(youth);
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { TicketPercent, Plus, X, Search, Pencil, Power, Users, CalendarClock, ListChecks } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -164,7 +164,7 @@ export default function CouponsPage() {
try {
const [c, ev] = await Promise.all([
apiFetch<Coupon[]>("/api/coupons", { authToken: token }),
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token }),
fetchManagedEvents<EventLite>(token, { past: true }),
]);
setCoupons(c || []);
setEvents(ev || []);
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Mail, Users, Zap, Clock } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
@@ -197,7 +197,7 @@ function EmailAttendeesPageInner() {
const loadEvents = useCallback(async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token || undefined });
const evs = await fetchManagedEvents<Event>(token || undefined, { past: true });
const sorted = (evs || []).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
setAllEvents(sorted);
} catch (e) {
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Ticket } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -126,7 +126,7 @@ function EventOptionsContent() {
const loadEvents = useCallback(async () => {
if (!token) return;
try {
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const evs = await fetchManagedEvents<Event>(token);
setEvents(evs || []);
} catch {
// ignore
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { createPortal } from "react-dom";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, API_BASE } from "@/lib/api";
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
@@ -22,6 +22,7 @@ import {
} from "@/components/events/ScheduleEditor";
import { toSessionDraft, scheduleSummary } from "@/lib/sessions";
import { fetchApiFile, openBlob } from "@/lib/export";
import { apiV2 } from "@/lib/apiV2";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -776,8 +777,8 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
(async () => {
setLoading(true);
try {
const res = await apiFetch<NotifyUser[]>(`/api/events/${eventId}/notify-recipients`, { authToken: token || undefined });
if (!cancelled) setSelected(Array.isArray(res) ? res : []);
const { data: res } = await apiV2("get", "/events/{id}/notify-recipients", { params: { id: eventId }, authToken: token });
if (!cancelled) setSelected(res);
} catch (e) {
if (!cancelled) setError(errorMessage(e) || "Failed to load notification recipients");
} finally {
@@ -794,10 +795,7 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
setSaving(true);
setError(null);
try {
await apiFetch(`/api/events/${eventId}/notify-recipients`, {
method: "PUT", authToken: token || undefined,
body: { userIds: next.map(u => u.id) },
});
await apiV2("put", "/events/{id}/notify-recipients", { params: { id: eventId }, authToken: token, body: { userIds: next.map(u => u.id) } });
} catch (e) {
setSelected(prev);
setError(errorMessage(e) || "Failed to save notification recipients");
@@ -1182,8 +1180,8 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
// Contact-only events have no ticket options to save — but a form (if any) still needs
// saving in edit mode; for create mode the form is already included in the POST body.
if (mode === "edit") {
await apiFetch(`/api/events/${eventId}`, {
method: "PUT", authToken: token || undefined,
await apiV2("patch", "/events/{id}", {
params: { id: eventId }, authToken: token,
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) } }
});
}
@@ -1233,8 +1231,8 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
}
// Handle form save (edit only)
await apiFetch(`/api/events/${eventId}`, {
method: "PUT", authToken: token || undefined,
await apiV2("patch", "/events/{id}", {
params: { id: eventId }, authToken: token,
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) } }
});
} else {
@@ -1412,24 +1410,24 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
if (mode === "edit") {
body.isActive = draft.isActive;
if (!ev) throw new Error("No event loaded to save");
const saved = await apiFetch<Event & { sessionChanges?: { moved: number; cancelled: number } | null }>(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
const { data: saved } = await apiV2("patch", "/events/{id}", { params: { id: ev.id }, authToken: token, body });
await saveOptions(ev.id);
// Form is saved inside saveOptions for edit mode; avoid double call
await saveFeedback(ev.id);
const ch = saved?.sessionChanges;
if (ch && (ch.moved > 0 || ch.cancelled > 0)) notice = { eventId: ev.id, moved: ch.moved, cancelled: ch.cancelled };
const ch = saved.sessionChanges;
if (ch && ((ch.moved ?? 0) > 0 || (ch.cancelled ?? 0) > 0)) notice = { eventId: ev.id, moved: ch.moved ?? 0, cancelled: ch.cancelled ?? 0 };
} else {
if (formDef.isRequired || formDef.fields.length > 0) {
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
}
if (cloning) body.clonedFromId = source!.id;
const created = await apiFetch<Event>("/api/events", { method: "POST", authToken: token || undefined, body });
const { data: created } = await apiV2("post", "/events", { authToken: token, body: body as typeof body & { title: string; startDate: string; endDate: string } });
if (created?.id) {
await saveOptions(created.id);
await saveFeedback(created.id);
// Groups (and planned costs, for admins) aren't in this form; the server copies them.
if (cloning) {
await apiFetch(`/api/events/${created.id}/copy-from`, { method: "POST", authToken: token || undefined, body: { fromEventId: source!.id } });
await apiV2("post", "/events/{id}/copy-from", { params: { id: created.id }, authToken: token, body: { fromEventId: source!.id } });
}
// Fetch event to get final option IDs for sections
const fullEvent = await apiFetch<Event>(`/api/events/${created.id}`).catch(() => null);
@@ -1443,10 +1441,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
}
// Save staged notify recipients
if (notifyRecipientDrafts.length > 0) {
await apiFetch(`/api/events/${created.id}/notify-recipients`, {
method: "PUT", authToken: token || undefined,
body: { userIds: notifyRecipientDrafts.map(u => u.id) },
}).catch(() => {});
await apiV2("put", "/events/{id}/notify-recipients", { params: { id: created.id }, authToken: token, body: { userIds: notifyRecipientDrafts.map(u => u.id) } }).catch(() => {});
}
}
}
@@ -2061,7 +2056,7 @@ export default function ManageEventsPage() {
if (!token) return;
setLoadingEvents(true);
try {
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const evs = await fetchManagedEvents<Event>(token, { past: true, inactive: true });
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
@@ -4,13 +4,14 @@ import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/s
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2 } from "@/lib/apiV2";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
@@ -170,7 +171,7 @@ export default function FormsBrowserPage() {
const loadEvents = useCallback(async () => {
if (!token) return;
try {
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const evs = await fetchManagedEvents<Event>(token, { past: true, inactive: true });
const sorted = (evs || []).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
setAllEvents(Array.isArray(sorted) ? sorted : []);
} catch {}
@@ -328,7 +329,7 @@ export default function FormsBrowserPage() {
setError(null); setInfo(null);
try {
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o) => String(o).trim()).filter(Boolean) : undefined }));
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}`, { method: 'PUT', authToken: token, body: { form: { isRequired: !!formDef.isRequired, fields } } });
await apiV2("patch", "/events/{id}", { params: { id: selectedEventId }, authToken: token, body: { form: { isRequired: !!formDef.isRequired, fields } } });
setInfo('Form saved');
} catch (e) {
setError(errorMessage(e) || 'Failed to save form');
@@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
@@ -235,7 +235,7 @@ export default function ManualRegistrationPage() {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
const evs = await fetchManagedEvents<Event>(token);
const now = Date.now();
const active = (evs || []).filter(ev => {
const t = new Date(ev.endDate).getTime();
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -41,7 +41,7 @@ export default function OfflineScansPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
fetchManagedEvents<Event>(token, { past: true })
.then(evs => {
const recent = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 14 * 864e5);
setEvents(recent);
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { apiFetch, fetchAllUsers, fetchAllPayments, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
@@ -375,9 +375,7 @@ function PaymentsContent() {
try {
if (!token) return;
// Past events too: the pickers open on upcoming ones and keep past ones a click away.
const params = new URLSearchParams({ includePast: "true" });
if (eventsIncludeInactive) params.set("includeInactive", "true");
const evs = await apiFetch<Event[]>(`/api/events/all?${params.toString()}`, { authToken: token });
const evs = await fetchManagedEvents(token, { past: true, inactive: eventsIncludeInactive });
setEvents(evs || []);
} catch {}
})();
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
@@ -58,7 +58,7 @@ export default function WaitlistPage() {
useEffect(() => {
if (!token || !canView) return;
apiFetch<Event[]>("/api/events/all", { authToken: token })
fetchManagedEvents<Event>(token)
.then(evs => {
const withList = (evs || []).filter(e => e.waitlistEnabled);
setEvents(withList);
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { apiFetch, fetchAllUsers, fetchManagedEvents } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
@@ -377,7 +377,7 @@ function WhatsAppAttendeesPageInner() {
const loadEvents = async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token || undefined });
const evs = await fetchManagedEvents<Event>(token || undefined, { past: true });
setAllEvents((evs || []).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
+2 -5
View File
@@ -2,7 +2,7 @@
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
@@ -319,10 +319,7 @@ export default function SelfServicePage() {
const loadEvents = useCallback(async (token: string) => {
setEventsLoading(true);
try {
const data: KioskEvent[] = await apiFetch(
"/api/events/all?includePast=false&includeInactive=false&excludeClosed=true",
{ authToken: token }
);
const data = await fetchManagedEvents<KioskEvent>(token, { closed: false });
setEvents(data);
} catch {
setEvents([]);
@@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { apiFetch, API_BASE, fetchManagedEvents } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck, Users, Ticket as TicketIcon } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
@@ -187,7 +187,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
useEffect(() => {
(async () => {
try {
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token || undefined });
const evs = await fetchManagedEvents<Event>(token || undefined, { past: true, inactive: true });
setEvents(Array.isArray(evs) ? evs : []);
} catch (e) {
setError(errorMessage(e) || "Failed to load events");
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Ticket } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import type { Event, SeasonPassView } from "@/types";
@@ -33,7 +33,7 @@ export function SeasonPassesPanel({ seriesId, seriesSlug }: { seriesId: string;
if (!token) return;
const [p, all] = await Promise.all([
apiFetch<SeasonPassView[]>(`/api/season-passes?seriesId=${encodeURIComponent(seriesId)}`, { authToken: token }).catch(() => []),
apiFetch<Event[]>("/api/events/all?includeInactive=true", { authToken: token }).catch(() => []),
fetchManagedEvents<Event>(token, { inactive: true }).catch(() => []),
]);
setPasses(p);
// Upcoming events in this series that take registrations, not youth events.
+1 -3
View File
@@ -1,9 +1,7 @@
// Next.js server instrumentation entry point. A no-op when NEXT_PUBLIC_SENTRY_DSN isn't
// set — see instrumentation-client.ts for the browser-side counterpart.
//
// Node runtime only, deliberately — this app's middleware.ts (which runs in Next's edge
// runtime) is a trivial pass-through with no real error surface, so an edge-runtime branch
// here would only inflate that shared middleware bundle for no actual coverage benefit.
// Node runtime only — the app has no middleware/proxy running in the edge runtime.
export async function register() {
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return;
if (process.env.NEXT_RUNTIME !== "nodejs") return;
File diff suppressed because it is too large Load Diff
+706 -3
View File
@@ -4,6 +4,28 @@
*/
export interface paths {
"/events/manage": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Every event staff can see
* @description Hidden ones too. Upcoming by default; `past`, `inactive` and `closed` (cashed-up) widen it. Campus-scoped staff see their campuses' events and church-wide ones. Not paged — it's what the event pickers search.
*
* **Who:** Staff, supervisors and admins.
*/
get: operations["getEventsManage"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/events": {
parameters: {
query?: never;
@@ -19,7 +41,13 @@ export interface paths {
*/
get: operations["getEvents"];
put?: never;
post?: never;
/**
* Create an event
* @description Gets a "Main Ticket" at the event price (unless registration is off). A campus-scoped supervisor must choose one of their campuses.
*
* **Who:** Supervisors and admins.
*/
post: operations["postEvents"];
delete?: never;
options?: never;
head?: never;
@@ -42,6 +70,66 @@ export interface paths {
get: operations["getEventsId"];
put?: never;
post?: never;
/**
* Deactivate an event
* @description Admins only. The event is deactivated, not deleted: registrations, payments and tickets stay.
*
* **Who:** Admins only.
*/
delete: operations["deleteEventsId"];
options?: never;
head?: never;
/**
* Change an event
* @description Only the fields sent change. Sessions save with the event (a refused schedule change saves nothing). A cashed-up event can't change until an admin reopens it. The answer includes `sessionChanges` (added, moved, cancelled… counts) when sessions were sent.
*
* **Who:** Supervisors and admins.
*/
patch: operations["patchEventsId"];
trace?: never;
};
"/events/{id}/copy-from": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Copy set-up from another event
* @description Allocation groups, and planned costs for admins — used right after copying an event.
*
* **Who:** Supervisors and admins.
*/
post: operations["postEventsIdCopy-from"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/events/{id}/notify-recipients": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Who gets this event's emails
* @description Registration, payment and daily-summary emails.
*
* **Who:** Supervisors and admins.
*/
get: operations["getEventsIdNotify-recipients"];
/**
* Set who gets this event's emails
* @description **Who:** Supervisors and admins.
*/
put: operations["putEventsIdNotify-recipients"];
post?: never;
delete?: never;
options?: never;
head?: never;
@@ -299,6 +387,8 @@ export interface components {
fields?: {
[key: string]: string;
};
/** @description A more specific cause when there is one, e.g. TWO_FACTOR_REQUIRED, IMPERSONATION_ENDED, LICENCE_READ_ONLY, OTHER_CAMPUS */
reason?: string;
};
};
};
@@ -310,6 +400,106 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
getEventsManage: {
parameters: {
query?: {
/** @description Include events that have ended */
past?: "true" | "false";
/** @description Include deactivated events */
inactive?: "true" | "false";
/** @description Include cashed-up events (default true) */
closed?: "true" | "false";
/** @description A campus id or slug, or 'all' for church-wide events only */
campus?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: ({
id: string;
title: string;
description: string | null;
/** Format: date-time */
startDate: string;
/** Format: date-time */
endDate: string;
isActive: boolean;
isHidden: boolean;
requiresRegistration: boolean;
/** @enum {string} */
scheduleType: "single" | "sessions";
/** @enum {string} */
attendanceMode: "whole" | "pick";
/** @enum {string} */
cashupStatus: "open" | "closed";
campusId: string | null;
seriesId: string | null;
picture: string | null;
eventOptions?: ({
id: string;
name: string;
price: number;
isMainTicket: boolean;
stockLimit: number;
} & {
[key: string]: unknown;
})[];
sessions?: ({
id: string;
label: string | null;
/** Format: date-time */
startsAt: string;
/** Format: date-time */
endsAt: string;
cancelled: boolean;
} & {
[key: string]: unknown;
})[];
} & {
[key: string]: unknown;
})[];
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
getEvents: {
parameters: {
query?: {
@@ -385,6 +575,116 @@ export interface operations {
};
};
};
postEvents: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
title: string;
description?: string | null;
startDate: string;
endDate: string;
price?: number | string;
campusId?: string | null;
seriesId?: string | null;
/** @enum {string} */
scheduleType?: "single" | "sessions";
sessions?: {
[key: string]: unknown;
}[];
} & {
[key: string]: unknown;
};
};
};
responses: {
/** @description OK */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
title: string;
description: string | null;
/** Format: date-time */
startDate: string;
/** Format: date-time */
endDate: string;
isActive: boolean;
isHidden: boolean;
requiresRegistration: boolean;
/** @enum {string} */
scheduleType: "single" | "sessions";
/** @enum {string} */
attendanceMode: "whole" | "pick";
/** @enum {string} */
cashupStatus: "open" | "closed";
campusId: string | null;
seriesId: string | null;
picture: string | null;
eventOptions?: ({
id: string;
name: string;
price: number;
isMainTicket: boolean;
stockLimit: number;
} & {
[key: string]: unknown;
})[];
sessions?: ({
id: string;
label: string | null;
/** Format: date-time */
startsAt: string;
/** Format: date-time */
endsAt: string;
cancelled: boolean;
} & {
[key: string]: unknown;
})[];
} & {
[key: string]: unknown;
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
getEventsId: {
parameters: {
query?: never;
@@ -480,6 +780,405 @@ export interface operations {
};
};
};
deleteEventsId: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
/** @constant */
isActive: false;
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
patchEventsId: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
title?: string;
description?: string | null;
startDate?: string;
endDate?: string;
price?: number | string;
campusId?: string | null;
seriesId?: string | null;
/** @enum {string} */
scheduleType?: "single" | "sessions";
sessions?: {
[key: string]: unknown;
}[];
} & {
[key: string]: unknown;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
title: string;
description: string | null;
/** Format: date-time */
startDate: string;
/** Format: date-time */
endDate: string;
isActive: boolean;
isHidden: boolean;
requiresRegistration: boolean;
/** @enum {string} */
scheduleType: "single" | "sessions";
/** @enum {string} */
attendanceMode: "whole" | "pick";
/** @enum {string} */
cashupStatus: "open" | "closed";
campusId: string | null;
seriesId: string | null;
picture: string | null;
eventOptions?: ({
id: string;
name: string;
price: number;
isMainTicket: boolean;
stockLimit: number;
} & {
[key: string]: unknown;
})[];
sessions?: ({
id: string;
label: string | null;
/** Format: date-time */
startsAt: string;
/** Format: date-time */
endsAt: string;
cancelled: boolean;
} & {
[key: string]: unknown;
})[];
sessionChanges: {
[key: string]: number;
} | null;
} & {
[key: string]: unknown;
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
"postEventsIdCopy-from": {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
fromEventId: string;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
[key: string]: unknown;
};
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
"getEventsIdNotify-recipients": {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
name: string;
email: string;
/** @enum {string} */
role: "admin" | "supervisor" | "staff" | "user";
}[];
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
"putEventsIdNotify-recipients": {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
userIds: string[];
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
data: {
id: string;
name: string;
email: string;
/** @enum {string} */
role: "admin" | "supervisor" | "staff" | "user";
}[];
};
};
};
/** @description Validation failed (VALIDATION_FAILED) */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not signed in (UNAUTHENTICATED) */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not allowed for this role (FORBIDDEN) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found (NOT_FOUND) */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
postRegistrations: {
parameters: {
query?: never;
@@ -1043,10 +1742,14 @@ export interface operations {
id: string;
eventId: string;
eventTitle: string | null;
/** @description For section checks */
ticketOptionId: string | null;
ticketName: string | null;
variant: string | null;
/** @description The attendee named on it, or the person who registered */
holder: string | null;
/** @description The attendee named on it, if any */
attendee: string | null;
/** @description Who registered */
bookedBy: string | null;
quantity: number;
/** @description Still to check in (this session, on multi-day events) */
remaining: number;
+23 -3
View File
@@ -1,4 +1,4 @@
import type { Paginated, Payment, User } from "@/types";
import type { Event, Paginated, Payment, User } from "@/types";
export type ApiOptions = {
method?: string;
@@ -123,10 +123,11 @@ export async function apiFetch<T = unknown>(path: string, options: ApiOptions =
const json = JSON.parse(text);
if (json?.message && typeof json.message === "string") message = json.message;
data = json;
// /api/v2 errors: { error: { code, message, fields? } }
// /api/v2 errors: { error: { code, message, fields?, reason? } }. `code` is the specific
// reason when there is one (TWO_FACTOR_REQUIRED, OTHER_CAMPUS…), like v1; v2Code is v2's own.
if (json?.error && typeof json.error === "object" && typeof json.error.message === "string") {
message = json.error.message;
data = { ...json.error };
data = { ...json.error, code: json.error.reason || json.error.code, v2Code: json.error.code };
}
} catch { /* not JSON — use raw text */ }
// An admin/supervisor session from before 2FA was required for their role:
@@ -196,6 +197,25 @@ async function fetchAllPages<T>(path: string, token: string, params: Record<stri
return items;
}
/**
* Every event staff can see (API v2 GET /events/manage): upcoming by default; `past`,
* `inactive` widen it, `closed: false` leaves out cashed-up events. Full records — ticket options,
* form, sessions, campus, series — for the event pickers and set-up screens.
*/
export async function fetchManagedEvents<T = Event>(token: string | null | undefined, opts: { past?: boolean; inactive?: boolean; closed?: boolean; campus?: string } = {}): Promise<T[]> {
const { apiV2 } = await import("@/lib/apiV2");
const { data } = await apiV2("get", "/events/manage", {
authToken: token,
query: {
past: opts.past ? "true" : "false",
inactive: opts.inactive ? "true" : "false",
closed: opts.closed === false ? "false" : "true",
...(opts.campus ? { campus: opts.campus } : {}),
},
});
return data as unknown as T[];
}
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<User[]> {
return fetchAllPages<User>("/api/users", token, params);
}
+18 -7
View File
@@ -1,5 +1,8 @@
// What the Ctrl+K command palette can jump to without searching (1.49.0): pages, settings tabs,
// reports and quick actions, each for the roles that can open it. `keywords` widen matching.
// Reports come from the report catalogue, so a new report shows up here by itself; a new page
// needs a line below (e2e/dashboard.spec.ts checks every dashboard page is listed or excluded).
import { REPORTS } from "@/components/reports/ReportCatalog";
export type Role = "staff" | "supervisor" | "admin";
export type CommandPage = { label: string; href: string; group: "Actions" | "Pages" | "Settings" | "Reports"; roles: Role[]; keywords?: string };
@@ -18,6 +21,10 @@ export const COMMAND_PAGES: CommandPage[] = [
{ group: "Actions", label: "Email attendees", href: "/dashboard/supervisor/email-attendees", roles: SUP, keywords: "message send mail" },
{ group: "Actions", label: "WhatsApp attendees", href: "/dashboard/supervisor/whatsapp-attendees", roles: SUP, keywords: "message send" },
// Pages
{ group: "Pages", label: "Dashboard", href: "/dashboard", roles: S, keywords: "home today waiting overview" },
{ group: "Pages", label: "Reports", href: "/dashboard/supervisor/reports", roles: SUP, keywords: "all reports export pdf excel" },
{ group: "Pages", label: "Ticket options", href: "/dashboard/supervisor/event-options", roles: SUP, keywords: "event options prices variants early bird" },
{ group: "Pages", label: "My groups", href: "/dashboard/staff/my-groups", roles: S, keywords: "rooms groups leader allocations" },
{ group: "Pages", label: "Events", href: "/dashboard/supervisor/events", roles: SUP, keywords: "manage edit copy" },
{ group: "Pages", label: "Event series", href: "/dashboard/supervisor/series", roles: SUP, keywords: "season pass follow" },
{ group: "Pages", label: "Registrations", href: "/dashboard/admin/registrations", roles: ADM, keywords: "orders cancel status" },
@@ -37,23 +44,27 @@ export const COMMAND_PAGES: CommandPage[] = [
{ group: "Pages", label: "Bundle deals", href: "/dashboard/supervisor/bundles", roles: SUP, keywords: "family deal" },
{ group: "Pages", label: "Event tickets & printing", href: "/dashboard/staff/event-tickets", roles: S, keywords: "print list" },
{ group: "Pages", label: "Offline scanning", href: "/dashboard/supervisor/offline-scans", roles: SUP, keywords: "devices sync" },
{ group: "Pages", label: "Attendee forms", href: "/dashboard/admin/forms", roles: ADM, keywords: "answers" },
{ group: "Pages", label: "Attendee forms", href: "/dashboard/supervisor/forms", roles: S, keywords: "answers questions responses" },
{ group: "Pages", label: "Audit log", href: "/dashboard/admin/audit-log", roles: ADM, keywords: "history who changed" },
{ group: "Pages", label: "Privacy (POPIA)", href: "/dashboard/admin/privacy", roles: ADM, keywords: "notice operators retention breaches readiness" },
{ group: "Pages", label: "Privacy requests", href: "/dashboard/admin/data-requests", roles: ADM, keywords: "delete download correction popia" },
{ group: "Pages", label: "My profile & security", href: "/dashboard/user/profile", roles: S, keywords: "password two-factor" },
// Their own side, as members (staff register for things too)
{ group: "Pages", label: "My events & tickets", href: "/dashboard/user", roles: S, keywords: "my registrations tickets" },
{ group: "Pages", label: "My payments", href: "/dashboard/user/payments", roles: S, keywords: "my receipts invoices" },
{ group: "Pages", label: "My privacy", href: "/dashboard/user/privacy", roles: S, keywords: "my data download delete news popia" },
{ group: "Pages", label: "My kids", href: "/dashboard/user/kids", roles: S, keywords: "children family pickup" },
{ group: "Pages", label: "My serving", href: "/dashboard/user/serving", roles: S, keywords: "volunteer roster cover" },
{ group: "Pages", label: "My attendee forms", href: "/dashboard/user/forms", roles: S, keywords: "fill in answers" },
{ group: "Pages", label: "Make a donation", href: "/dashboard/user/donate", roles: S, keywords: "give donate" },
// Settings
...([
["organisation", "Organisation"], ["campuses", "Campuses"], ["branding", "Branding"], ["notifications", "Notifications"],
["email", "Email"], ["legal", "Legal"], ["whatsapp", "WhatsApp"], ["payments", "Payments"], ["webhooks", "Webhooks"],
["backups", "Backups"], ["jobs", "Background jobs"], ["licence", "Licence & updates"],
] as const).map(([tab, label]): CommandPage => ({ group: "Settings", label: `Settings: ${label}`, href: `/dashboard/admin/settings?tab=${tab}`, roles: ADM, keywords: "site settings" })),
// Reports
...([
["payments", "Payments between dates"], ["revenue", "Revenue summary"], ["regStatus", "Registration status"],
["funnel", "Registration funnel"], ["feedbackSummary", "Feedback summary"], ["usage", "Ticket usage"],
["financeReport", "Finance report"], ["seriesComparison", "Series: year on year"], ["discounts", "Discounts"],
] as const).map(([key, label]): CommandPage => ({ group: "Reports", label: `Report: ${label}`, href: `/dashboard/supervisor/reports?report=${key}`, roles: SUP, keywords: "report" })),
// Reports — every report in the catalogue
...REPORTS.map((r): CommandPage => ({ group: "Reports", label: `Report: ${r.label}`, href: `/dashboard/supervisor/reports?report=${r.key}`, roles: SUP, keywords: `report ${r.category} ${r.description}` })),
];
/** Pages for a role, matching every word of the query (in the label or keywords). */
-6
View File
@@ -1,6 +0,0 @@
import { NextResponse } from "next/server";
// Minimal pass-through middleware to satisfy Next.js requirements
export function middleware() {
return NextResponse.next();
}
+2 -2
View File
@@ -11,7 +11,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -22,6 +22,6 @@
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", ".next-e2e/types/**/*.ts", ".next-e2e/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.51.2",
"version": "1.51.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.51.2",
"version": "1.51.7",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.51.2",
"version": "1.51.7",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
+7
View File
@@ -53,6 +53,13 @@ checkout() {
echo "==> Deploying $TARGET (currently $PREVIOUS)"
# Node 20 reached end of life in April 2026 (no more security fixes). Releases still run on it,
# but the server should move to Node 24 LTS — see DEPLOYMENT.md, "Upgrading Node".
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "!!> This server runs Node $(node -v), which no longer gets security fixes. Upgrade to Node 24 LTS (DEPLOYMENT.md, \"Upgrading Node\")."
fi
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); })")
+5 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Installs Cross Events on a fresh Ubuntu server (22.04 or 24.04): Node 20, PostgreSQL, PM2,
# Installs Cross Events on a fresh Ubuntu server (22.04 or 24.04): Node 24 LTS, PostgreSQL, PM2,
# nginx and an HTTPS certificate, then the app itself. Finish in the browser at /setup.
#
# Usage (as root, or with sudo):
@@ -58,9 +58,10 @@ apt-get update -q
apt-get install -y -q curl git nginx postgresql postgresql-client openssl build-essential ca-certificates gnupg
[ "$SSL" = 0 ] || apt-get install -y -q certbot python3-certbot-nginx
step "Node.js 20"
if ! command -v node >/dev/null || [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 20 ]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
# Node 24 is the LTS line supported until April 2028 (Node 20 reached end of life in April 2026).
step "Node.js 24"
if ! command -v node >/dev/null || [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 24 ]; then
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt-get install -y -q nodejs
fi
node -v