Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0dae5647f | ||
|
|
4109cb4258 |
@@ -7,6 +7,27 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
+4
-2
@@ -892,8 +892,10 @@ can't start, the same jobs run on in-process timers.
|
||||
- Backend status page (`/`) and built-in API docs page (`/docs`); **API v2** (`/api/v2`, 1.51.0)
|
||||
with one error shape, paged lists and an OpenAPI 3.1 reference at `/docs/v2` (admins). Public
|
||||
events, my account and registrations, registering, cancelling and paying online (1.51.1),
|
||||
signing up, logging in (with the 2FA step), changing my details and checking tickets in (1.51.2);
|
||||
the frontend's v2 calls are typed from the OpenAPI document.
|
||||
signing up, logging in (with the 2FA step), changing my details and checking tickets in (1.51.2;
|
||||
the scanner and at-the-door use it from 1.51.3); the frontend's v2 calls are typed from the
|
||||
OpenAPI document. v1 endpoints with a v2 replacement send `Deprecation` and a `successor-version`
|
||||
link (1.51.3).
|
||||
- **TypeScript backend** (1.51.0): `.ts` files are checked strictly, `.js` compiles as it is;
|
||||
deploys build `dist/`, which `src/index.js` runs.
|
||||
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
|
||||
|
||||
@@ -12,6 +12,12 @@ 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.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
+393
-528
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.2",
|
||||
"version": "1.51.3",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -29,7 +29,7 @@
|
||||
"cron-parser": "^4.9.0",
|
||||
"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",
|
||||
@@ -47,7 +47,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
|
||||
+12
-6
@@ -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.3** (and Hub 1.5.0). The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
|
||||
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
|
||||
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
|
||||
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
|
||||
@@ -64,9 +65,9 @@ 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. **Decide how far v1 goes before 2.0** (roadmap "Still open"): v2 covers what members, guests and
|
||||
the scanner use; the staff/supervisor/admin screens still call ~350 v1 routes. Then **2.0.0 —
|
||||
AI helpers** (§15), AI endpoints on v2 only.
|
||||
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
|
||||
@@ -485,8 +486,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)
|
||||
|
||||
|
||||
+11
-4
@@ -39,8 +39,8 @@ report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound
|
||||
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
|
||||
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
|
||||
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
|
||||
foundations**, 1.51.1 **v2 registrations and payments** and 1.51.2 **v2 accounts and check-in** shipped.
|
||||
**Next: 1.51.3 — the rest of v2, Express 5.**
|
||||
foundations**, 1.51.1 **v2 registrations and payments**, 1.51.2 **v2 accounts and check-in** and 1.51.3 **Express 5,
|
||||
scanner on v2, v1 deprecation headers** shipped. **Next: 2.0.0 (AI helpers) — see "Still open" on v1.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -1008,7 +1008,7 @@ 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
@@ -1068,4 +1068,11 @@ feature.
|
||||
|
||||
## Still open
|
||||
|
||||
Nothing for now.
|
||||
- **How much of v1 goes before 2.0** (raised 2026-10-29, after 1.51.3). v2 now covers what members,
|
||||
guests and the scanner use (events, accounts, registering, paying, cancelling, check-in). The
|
||||
staff, supervisor and admin screens still call about 350 v1 routes (reports, cashup, messaging,
|
||||
settings, kids, volunteers…). Porting them all is several releases of work with little gain for
|
||||
churches. Options: (a) port everything, then remove v1 in 2.0 as planned; (b) **recommended** —
|
||||
2.0 removes only the v1 endpoints that have a v2 replacement, and the rest stays as an internal API
|
||||
(no deprecation header, not in the public docs) that moves to v2 when each screen is next
|
||||
worked on; (c) keep all of v1 through 2.0 and decide later.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.2",
|
||||
"version": "1.51.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.2",
|
||||
"version": "1.51.3",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.2",
|
||||
"version": "1.51.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2207,6 +2207,19 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ticketOptionId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "For section checks"
|
||||
},
|
||||
"ticketName": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -2227,7 +2240,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"holder": {
|
||||
"attendee": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
@@ -2236,7 +2249,18 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "The attendee named on it, or the person who registered"
|
||||
"description": "The attendee named on it, if any"
|
||||
},
|
||||
"bookedBy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Who registered"
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer",
|
||||
@@ -2294,9 +2318,11 @@
|
||||
"id",
|
||||
"eventId",
|
||||
"eventTitle",
|
||||
"ticketOptionId",
|
||||
"ticketName",
|
||||
"variant",
|
||||
"holder",
|
||||
"attendee",
|
||||
"bookedBy",
|
||||
"quantity",
|
||||
"remaining",
|
||||
"session",
|
||||
@@ -3201,6 +3227,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:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.2",
|
||||
"version": "1.51.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.51.2",
|
||||
"version": "1.51.3",
|
||||
"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.3",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user