Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2906011fd | ||
|
|
70850df4f6 | ||
|
|
cbebcdbd48 | ||
|
|
849998acb3 | ||
|
|
c5fe17a1ef | ||
|
|
6164fdcb3b | ||
|
|
1510c2a057 | ||
|
|
fa4191d18c | ||
|
|
e0dae5647f | ||
|
|
4109cb4258 |
@@ -7,6 +7,90 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
@@ -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
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -12,6 +12,29 @@ 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.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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+439
-1437
File diff suppressed because it is too large
Load Diff
+11
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.2",
|
||||
"version": "1.51.6",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -20,22 +20,22 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -72,5 +72,8 @@
|
||||
"@parcel/watcher": false,
|
||||
"@scarf/scarf": false,
|
||||
"esbuild": false
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
|
||||
+38
-9
@@ -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.6** (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,10 @@ 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.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 +66,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.7 – 1.51.13: 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.13 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:
|
||||
pg-boss 11 (it would drop pg-boss 10's cron-parser 4), and move from ExcelTS — deprecated in
|
||||
favour of `documonster` by the same author (pre-1.0 in Oct 2026) — once documonster is stable.
|
||||
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 +88,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 +100,8 @@ 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 is still on 10.x (11 is the next step).
|
||||
- **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 +461,24 @@ 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).
|
||||
|
||||
## 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 +509,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)
|
||||
|
||||
|
||||
+7
-4
@@ -39,8 +39,9 @@ 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** shipped. **Next: 1.51.7 – 1.51.13 (every endpoint onto v2), then 2.0.0.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -1008,8 +1009,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.13 | **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 events and event set-up (events, sections, costs, uploads, series, season passes, bundles, coupons, campuses); 1.51.8 registrations and money, staff side (manage registrations, payments, cashup, provider transactions, waitlist, sponsorship, consent, forms, feedback); 1.51.9 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.10 kids check-in and volunteer rosters; 1.51.11 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.12 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.13 **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 +1048,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". Incoming webhooks (Yoco, PayFast, WAWP) and links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds) also get v2 URLs (1.51.13); 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) |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Generated
+886
-670
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.2",
|
||||
"version": "1.51.6",
|
||||
"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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+8
-2
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Minimal pass-through middleware to satisfy Next.js requirements
|
||||
export function middleware() {
|
||||
return NextResponse.next();
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.2",
|
||||
"version": "1.51.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.51.2",
|
||||
"version": "1.51.6",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.2",
|
||||
"version": "1.51.6",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user