Compare commits

..
6 Commits
Author SHA1 Message Date
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
29 changed files with 1181 additions and 1193 deletions
+48
View File
@@ -7,6 +7,54 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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 |
+4 -2
View File
@@ -892,8 +892,10 @@ 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.
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
+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**
+11
View File
@@ -12,6 +12,17 @@ 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.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:
+427 -550
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.2",
"version": "1.51.4",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -26,16 +26,16 @@
"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",
"qrcode": "^1.5.4",
@@ -44,10 +44,10 @@
"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",
+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) => {
+3 -3
View File
@@ -15,7 +15,7 @@
* 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';
@@ -105,7 +105,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 +116,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) {
+52
View File
@@ -0,0 +1,52 @@
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]}` },
{ 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();
};
+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,
},
},
+32 -1
View File
@@ -333,6 +333,37 @@ 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')).toBeNull();
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' } });
});
});
+20 -8
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.4** (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,6 +14,7 @@ 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.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` |
@@ -64,9 +65,13 @@ 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.5 — Next.js 16 and ESLint 10**, then **1.51.6 – 1.51.11: every endpoint onto v2**, one area
per release (roadmap build order, row 11b): services in TS, screens on `apiV2`, v1 routes into
`SUCCESSORS`. Then **2.0.0** — AI helpers and v1 removed. Inbound provider webhooks and links
already sent in emails stay as permanent aliases.
2. **Waiting on Joshua:** upgrade servers to Node 24 (DEPLOYMENT.md, "Upgrading Node"); approve
replacing exceljs (unmaintained, five deprecated sub-packages) with ExcelTS
(`@cj-tech-master/excelts`, same API, no dependencies — 5.x on Node 20, 10.x on Node 22+).
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
@@ -94,8 +99,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:** new installs get Node 24 LTS (1.51.4). Older servers may still run
Node 20 (end of life April 2026) and `deploy.sh` warns on them; until every server is on 24,
dependencies must still support Node 20 (that's why pg-boss is pinned to 10.x and ExcelTS would
have to stay on 5.x). Once they're all on 24: pg-boss 11, ExcelTS 10.
- **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
@@ -485,8 +492,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)
+6 -4
View File
@@ -39,8 +39,8 @@ 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. **Next: 1.51.4 – 1.51.11 (dependencies, Next.js 16, then every endpoint onto v2), then 2.0.0.**
**Repositories:**
@@ -1008,8 +1008,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.11 | **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 events and event set-up (events, sections, costs, uploads, series, season passes, bundles, coupons, campuses); 1.51.7 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.8 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.9 kids check-in and volunteer rosters; 1.51.10 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.11 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups). 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; inbound provider webhooks and links already sent in emails stay as permanent aliases), 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 +1047,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.6 – 1.51.11, by area), then 2.0 removes v1. Changed the same day from "retire only what's replaced". Kept for good: inbound provider webhooks (Yoco, PayFast, WAWP post to URLs registered with them) and links already sent in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) — as permanent aliases, outside the versioned API (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) |
+7
View File
@@ -84,6 +84,13 @@ 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"`);
expect((await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } })).headers()["deprecation"]).toBeUndefined();
});
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);
+290 -362
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.51.2",
"version": "1.51.4",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -29,14 +29,14 @@
"@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": "^15.5.28",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-day-picker": "^9.8.1",
@@ -45,7 +45,7 @@
"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",
@@ -55,7 +55,7 @@
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"eslint": "^9",
"eslint-config-next": "^15.5.24",
"eslint-config-next": "^15.5.28",
"openapi-typescript": "^7.13.0",
"postcss": "^8.5.6",
"tailwindcss": "3.4",
@@ -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;
@@ -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");
}
}
@@ -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" };
@@ -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) {
+133 -203
View File
@@ -116,21 +116,17 @@
"startDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"endDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"location": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"price": {
@@ -138,13 +134,9 @@
"description": "The headline price in rand (0 = free)"
},
"pictureUrl": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"requiresRegistration": {
@@ -333,21 +325,17 @@
"startDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"endDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"location": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"price": {
@@ -355,13 +343,9 @@
"description": "The headline price in rand (0 = free)"
},
"pictureUrl": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"requiresRegistration": {
@@ -434,13 +418,9 @@
]
},
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"registrationDeadline": {
@@ -448,7 +428,7 @@
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
{
"type": "null"
@@ -510,33 +490,25 @@
"maxLength": 64
},
"label": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"startsAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"endsAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"location": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"cancelled": {
@@ -692,7 +664,7 @@
"type": "string",
"maxLength": 200,
"format": "email",
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
"pattern": "^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
},
"phone": {
"type": "string",
@@ -876,7 +848,7 @@
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"event": {
"type": "object",
@@ -892,12 +864,12 @@
"startDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"endDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
@@ -917,13 +889,9 @@
"type": "string"
},
"variant": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"quantity": {
@@ -961,13 +929,9 @@
"description": "false = added to an existing registration"
},
"couponError": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
}
},
@@ -1062,7 +1026,7 @@
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"event": {
"type": "object",
@@ -1078,12 +1042,12 @@
"startDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"endDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
@@ -1103,13 +1067,9 @@
"type": "string"
},
"variant": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"quantity": {
@@ -1252,7 +1212,7 @@
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"event": {
"type": "object",
@@ -1268,12 +1228,12 @@
"startDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"endDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
@@ -1293,13 +1253,9 @@
"type": "string"
},
"variant": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"quantity": {
@@ -1593,13 +1549,9 @@
"type": "string"
},
"phone": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"role": {
@@ -1637,7 +1589,7 @@
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
@@ -1703,7 +1655,7 @@
"type": "string",
"maxLength": 200,
"format": "email",
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
"pattern": "^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
},
"phone": {
"description": "null or empty removes it",
@@ -1775,13 +1727,9 @@
"type": "string"
},
"phone": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"role": {
@@ -1819,7 +1767,7 @@
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
@@ -1955,7 +1903,7 @@
"createdAt": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"event": {
"type": "object",
@@ -1971,12 +1919,12 @@
"startDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"endDate": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
@@ -1996,13 +1944,9 @@
"type": "string"
},
"variant": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"quantity": {
@@ -2172,15 +2116,11 @@
]
},
"message": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "For a transferred ticket: who has it now"
"description": "For a transferred ticket: who has it now",
"type": [
"string",
"null"
]
},
"ticket": {
"anyOf": [
@@ -2198,45 +2138,49 @@
"maxLength": 64
},
"eventTitle": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"ticketName": {
"ticketOptionId": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"variant": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"holder": {
"anyOf": [
{
"type": "string"
"type": "string",
"minLength": 1,
"maxLength": 64
},
{
"type": "null"
}
],
"description": "The attendee named on it, or the person who registered"
"description": "For section checks"
},
"ticketName": {
"type": [
"string",
"null"
]
},
"variant": {
"type": [
"string",
"null"
]
},
"attendee": {
"description": "The attendee named on it, if any",
"type": [
"string",
"null"
]
},
"bookedBy": {
"description": "Who registered",
"type": [
"string",
"null"
]
},
"quantity": {
"type": "integer",
@@ -2275,15 +2219,11 @@
]
},
"sessionError": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Why no session is open for check-in now"
"description": "Why no session is open for check-in now",
"type": [
"string",
"null"
]
},
"consentStatus": {
"type": "string",
@@ -2294,9 +2234,11 @@
"id",
"eventId",
"eventTitle",
"ticketOptionId",
"ticketName",
"variant",
"holder",
"attendee",
"bookedBy",
"quantity",
"remaining",
"session",
@@ -2482,25 +2424,17 @@
"maxLength": 64
},
"eventTitle": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"ticketName": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "The ticket option, e.g. \"Adult\""
"description": "The ticket option, e.g. \"Adult\"",
"type": [
"string",
"null"
]
},
"quantity": {
"type": "integer",
@@ -2568,13 +2502,9 @@
]
},
"consentStatus": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
"type": [
"string",
"null"
]
},
"canOverride": {
@@ -2671,7 +2601,7 @@
"type": "string",
"maxLength": 200,
"format": "email",
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
"pattern": "^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
},
"password": {
"type": "string",
@@ -2962,15 +2892,11 @@
]
},
"destination": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Where the code went, partly hidden"
"description": "Where the code went, partly hidden",
"type": [
"string",
"null"
]
},
"enrolling": {
"type": "boolean",
@@ -3201,6 +3127,10 @@
"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"
}
}
}
+8 -2
View File
@@ -299,6 +299,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;
};
};
};
@@ -1043,10 +1045,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;
+3 -2
View File
@@ -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:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.51.2",
"version": "1.51.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.51.2",
"version": "1.51.4",
"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.4",
"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