Compare commits

...
Author SHA1 Message Date
joshua 7ce25bf40b Merge branch 'docs/handover-roadmap-tidy' 2026-10-30 09:00:49 +02:00
joshuaandClaude Opus 5.5 376b93a438 Docs: bring HANDOVER and ROADMAP-v2 up to date after 1.51.17
HANDOVER: version table in order with 1.51.4/1.51.5 added; Next up
renumbered with the 2.0 clean-ups (lint rules, node-fetch, password
hashing); event picker points at fetchManagedEvents; stale lines fixed.
ROADMAP: status rewritten as a dated summary; section 14 marks what's
done and that CI was removed; Haiku prices flagged to check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:00:49 +02:00
joshua 0cd78ab1b7 Merge branch 'fix/forgot-password-enumeration' 2026-10-30 08:51:41 +02:00
joshuaandClaude Opus 5.5 d49aebb1b0 Security: forgot password and sign-in don't reveal accounts (1.51.17)
- Forgot password answers the same (message, status, timing) for every email; the reset link
  is made and sent after the answer. Rate-limited like sign-in.
- Sign-in with an unknown email is refused exactly like a wrong password, after a password
  check against a stand-in hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:51:41 +02:00
joshuaandClaude Opus 5.5 98e5d05548 WIP 1.51.17: forgot password and sign-in don't reveal accounts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:47:04 +02:00
joshua 14ece27690 Merge branch 'feat/api-v2-webhooks-links' 2026-10-30 08:34:41 +02:00
18 changed files with 268 additions and 138 deletions
+19
View File
@@ -7,6 +7,25 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.51.17] - 2026-10-30 — security release
### Security
- **Forgot password no longer reveals whether an account exists.** It answered "Email not found"
(404) for an unknown address, so anyone could check which emails have accounts. It now gives
the same answer and status for every address, sent before any work that only happens for a
real account (so the response time doesn't tell either); the reset link is made and sent
afterwards. Applies to `POST /api/users/forgot` and `POST /api/v2/auth/password/forgot`.
- **Sign-in no longer reveals it either.** An unknown email answered "User does not exist" (with a
different status on v1); it now gets exactly the answer a wrong password gets, after a password
check against a stand-in hash so it takes as long.
- Forgot password is rate-limited like sign-in, so it can't be used to flood someone's inbox with
reset emails.
### Upgrading
- Update as soon as you can. Nothing else changes; the forgot-password page shows the new message.
## [1.51.16] - 2026-10-30
### Added
+5
View File
@@ -12,6 +12,11 @@ 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.17 — security update
- **Better protection for people's accounts.** The forgot-password and sign-in pages no longer
let anyone find out whether an email address has an account here.
## 1.51.16
- **The move to the new server connection is complete.** Payment confirmations from Yoco and
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.51.16",
"version": "1.51.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.51.16",
"version": "1.51.17",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.16",
"version": "1.51.17",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+41 -44
View File
@@ -539,55 +539,52 @@ const requestPasswordReset = async (req, res) => {
const user = await prisma.user.findUnique({ where: { email } });
// Explicitly check existence as requested
if (!user) {
return res.status(404).json({ message: 'Email not found' });
}
// Create token valid for 1 hour
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// If PasswordReset model isn't available (migration not run), return clear error
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
}
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
// Security (1.51.17): the same answer, with the same status, whether or not an account has
// this email — and it's sent before any work that only happens for real accounts, so the
// response time doesn't tell either. The link is made and sent afterwards.
res.json({ message: GENERIC_RESET_MESSAGE });
if (user) sendPasswordResetLink(user).catch(e => console.warn('[password reset] Failed:', e?.message || e));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
const GENERIC_RESET_MESSAGE = 'If an account uses that email, we\'ve sent it a link to reset the password.';
/** A one-hour reset link for this account, by email (and WhatsApp when they prefer it). */
async function sendPasswordResetLink(user) {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
}
// @desc Reset password using token
// @route POST /api/users/reset
// @access Public
+1 -1
View File
@@ -33,7 +33,7 @@ router.post('/', registerUser);
router.post('/login', loginLimiter, loginUser);
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
router.post('/forgot', requestPasswordReset);
router.post('/forgot', loginLimiter, requestPasswordReset);
router.post('/reset', resetPassword);
router.post('/activate', activateAccount);
+10 -1
View File
@@ -207,6 +207,10 @@ export type LoginResult =
| { status: 'signed_in'; session: Session }
| { status: 'code_required'; challenge: { challengeId: string; method: string; destination: string | null }; enrolling: boolean };
let standIn: Promise<string> | null = null;
/** A hash nobody's password matches, so a sign-in with an unknown email does the same work. */
const standInHash = () => (standIn ??= auth.hashPassword(uuidv4()));
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 5 * 60 * 1000;
@@ -219,7 +223,12 @@ export async function logIn(input: { identifier: string; password: string; devic
// SA local format (0xx) → international (27xx)
const normalizedPhone = whatsapp().normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
const user = await prisma.user.findFirst({ where: { OR: [{ email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : [])] } });
if (!user) return fail(401, 'User does not exist');
// Security (1.51.17): an unknown email answers like a wrong password — same message and
// status, and a password check against a stand-in hash so it takes as long.
if (!user) {
await auth.comparePassword(input.password, await standInHash());
return fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
}
if (!user.isActive) {
// Resend the activation link on each attempt, in case the earlier one expired.
+1 -1
View File
@@ -44,7 +44,7 @@ const qs = (req: Request) => (req.originalUrl.includes('?') ? req.originalUrl.sl
// ─── Accounts ────────────────────────────────────────────────────────────────
v1Route({ method: 'post', path: '/auth/login/code/resend', tag: 'Accounts', summary: 'Send the sign-in code again', access: 'public', middleware: [loginLimiter], body: loose, v1: users.resendLoginTwoFactor });
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'NOT_FOUND when no account has that email (as v1 does).', access: 'public', body: z.looseObject({ email: z.string().max(200) }), v1: users.requestPasswordReset });
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'Always the same answer, so it doesn\'t say whether an account has that email. Rate-limited like sign-in.', access: 'public', middleware: [loginLimiter], body: z.looseObject({ email: z.string().max(200) }), v1: users.requestPasswordReset });
v1Route({ method: 'post', path: '/auth/password/reset', tag: 'Accounts', summary: 'Set a new password with a reset link', access: 'public', body: loose, v1: users.resetPassword });
v1Route({ method: 'post', path: '/auth/activate', tag: 'Accounts', summary: 'Activate an account with its link', access: 'public', body: loose, v1: users.activateAccount });
v1Route({ method: 'post', path: '/me/sessions/revoke', tag: 'Me', summary: 'Sign out everywhere else', access: 'user', body: loose, v1: users.revokeMySession });
+83
View File
@@ -0,0 +1,83 @@
/**
* 1.51.17 security fix: nothing public says whether an account exists. Forgot-password answers
* the same for every email (before doing any per-account work), and sign-in with an unknown email
* answers exactly like a wrong password.
*/
const mockDb = {
user: { findUnique: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
passwordReset: { updateMany: jest.fn(), create: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockCompare = jest.fn(async () => false);
jest.mock('../src/config/auth', () => ({ comparePassword: (...a: unknown[]) => (mockCompare as jest.Mock)(...a), hashPassword: async () => 'stand-in-hash', generateToken: () => 't' }));
const mockSendMail = jest.fn(async () => undefined);
jest.mock('../src/utils/email', () => ({ sendMail: (...a: unknown[]) => (mockSendMail as jest.Mock)(...a), buildPasswordResetEmail: () => ({ html: '', text: 'reset' }) }));
jest.mock('../src/utils/notify', () => ({ waText: async () => undefined }));
jest.mock('../src/utils/securityEvents', () => ({ logSecurityEvent: async () => undefined, getRecentSecurityEvents: async () => [] }));
jest.mock('../src/utils/whatsapp', () => ({ normalizeZAPhone: () => null, isValidZAPhone: () => false }));
import { logIn } from '../src/services/auth';
import { ServiceError } from '../src/services/errors';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { requestPasswordReset } = require('../src/controllers/userController');
type Answer = { status: number; body: unknown };
function call(handler: (req: unknown, res: unknown) => Promise<void>, body: object): Promise<Answer> {
return new Promise(resolve => {
let status = 200;
const res = {
statusCode: 200,
status(n: number) { status = n; this.statusCode = n; return this; },
json(b: unknown) { resolve({ status, body: b }); return this; },
};
handler({ body, headers: {}, ip: '127.0.0.1' }, res);
});
}
const realUser = { id: 'u1', name: 'Thandi', email: 'thandi@example.org', isActive: true, password: 'real-hash', failedAttempts: 0, lockUntil: null, role: 'user' };
beforeEach(() => jest.clearAllMocks());
describe('forgot password', () => {
test('answers the same, with the same status, for an email with and without an account', async () => {
mockDb.user.findUnique.mockResolvedValueOnce(null);
const unknown = await call(requestPasswordReset, { email: 'nobody@example.org' });
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
const known = await call(requestPasswordReset, { email: realUser.email });
expect(unknown).toEqual(known);
expect(known.status).toBe(200);
});
test('only a real account gets a link, made after the answer is sent', async () => {
mockDb.user.findUnique.mockResolvedValueOnce(null);
await call(requestPasswordReset, { email: 'nobody@example.org' });
await new Promise(r => setImmediate(r));
expect(mockDb.passwordReset.create).not.toHaveBeenCalled();
expect(mockSendMail).not.toHaveBeenCalled();
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
const answered = call(requestPasswordReset, { email: realUser.email });
await answered;
await new Promise(r => setImmediate(r));
expect(mockDb.passwordReset.create).toHaveBeenCalledTimes(1);
expect(mockSendMail).toHaveBeenCalledWith(expect.objectContaining({ to: realUser.email }));
});
});
describe('sign-in', () => {
const failure = async (p: Promise<unknown>) => { try { await p; } catch (e) { return e as ServiceError; } throw new Error('expected a refusal'); };
test('an unknown email is refused exactly like a wrong password', async () => {
mockDb.user.findFirst.mockResolvedValueOnce(null);
const unknown = await failure(logIn({ identifier: 'nobody@example.org', password: 'x' }, { ip: '1', userAgent: 'jest' }));
mockDb.user.findFirst.mockResolvedValueOnce(realUser);
const wrong = await failure(logIn({ identifier: realUser.email, password: 'x' }, { ip: '1', userAgent: 'jest' }));
expect({ message: unknown.message, status: unknown.statusCode, v1: unknown.v1Status }).toEqual({ message: wrong.message, status: wrong.statusCode, v1: wrong.v1Status });
});
test('an unknown email still does a password check, so it takes as long', async () => {
mockDb.user.findFirst.mockResolvedValueOnce(null);
await failure(logIn({ identifier: 'nobody@example.org', password: 'guess' }, { ip: '1', userAgent: 'jest' }));
expect(mockCompare).toHaveBeenCalledWith('guess', 'stand-in-hash');
});
});
+47 -32
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-30, after **1.51.16** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-30, after **1.51.17** (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,9 +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.17 | **Security:** forgot-password and sign-in don't reveal whether an account exists (same answer, status and timing); forgot-password rate-limited |
| 1.51.16 | **Incoming webhooks and message links on v2** — `src/v2/links.ts` (`/api/v2/webhooks/…`, one-click unsubscribe, 308s for old links); provider URLs and email links point at v2 |
| 1.51.15 | **Privacy and admin on v2** — `resources/adminPrivacy.ts`; every frontend call on v2; impersonation end/log allowed on v2 while read-only |
| 1.51.14 | **People and messaging on v2** — `resources/peopleMessaging.ts`; `/event-listing` for the public pages; `defineRoute` refuses duplicates |
@@ -28,6 +26,11 @@ encrypted settings unreadable or break existing installs.
| 1.51.8 | **Rest of event set-up on v2**: event in full, calendar, alias, ticket options and variants, attachments, sections, costs, image uploads — `src/services/eventDetails.ts`, `src/services/setup.ts`; frontend `fetchEvent()` and `lib/eventSetup.ts`; v2 `file` and `upload` routes |
| 1.51.7 | **Setting events up on v2** (`/events/manage`, create, `PATCH`, deactivate, copy-from, notify-recipients) via `src/services/events.ts`; `fetchManagedEvents()` for every picker; **pg-boss 11** in schema `pgboss_v11` |
| 1.51.6 | Command palette lists every report (from `ReportCatalog`) and every dashboard page (e2e drift check); exceljs → ExcelTS 10 (`@cj-tech-master/excelts`, no dependencies; e2e `e2e/excel.spec.ts`); backend `engines.node` >= 22 |
| 1.51.5 | **Next.js 16** and React 19.3 (Turbopack), **ESLint 10** with Next 16's flat configs (`npm run lint` = `eslint src`); React Compiler-era rules as warnings for now; empty `middleware.ts` removed |
| 1.51.4 | **Security and dependency updates**: Next.js 15.5.28, @sentry/nextjs, zod, multer, nodemailer and others for published advisories; minimum versions raised in package.json; cron-parser 5 |
| 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` |
| 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` |
@@ -43,9 +46,9 @@ encrypted settings unreadable or break existing installs.
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
| 1.47.0 | **Copy an event** (§6 part 1): "Copy" opens the event editor filled in from the source (campus, sessions, options, tiers, sections, form, recipients, feedback, certificates); "New start" moves every date; allocation groups and (admins) costs copied by `POST /api/events/:id/copy-from`. No registrations, coupons, attachments, kids check-in or volunteer roles |
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
| 1.46.2 | Licence page: what's new (from WHATS-NEW.md via the Hub), withdraw update request, "Not now", urgent updates, provider notices banner (Hub 1.4.0: releases from git tags, Notices page) |
| 1.46.1 | Removed the Gitea CI workflow (no runner): test, lint and deploy by hand |
| 1.46.0 | **Volunteer rosters** (§5): serving roles per event or session, anyone signs up (or supervisors assign), day-before reminders, "can't make it" swaps that happen on accept (notify recipients emailed), self check-in with the event's QR code or by staff, kids-room roles count as leaders on duty, free volunteer ticket off by default, roster sheet and hours served. Also fixes the 1.45.0 kids ticket being offered publicly |
| 1.45.1 | Privacy notice (built-in text) and terms of use brought up to date with kids check-in, sessions, certificates, campuses, the Hub heartbeat and where data is stored. Installs with a published notice must publish a new version themselves |
| 1.45.0 | **Kids check-in** (§4), fully digital: families/children/authorised adults kept between events (My account → Kids), staff desk with pickup codes and a "write on the sticker" panel, rooms with age ranges and ratios, room screen (on duty, call parent, alerts), check-out by code (wrong code alerts, override with reason), offline desk with reserved codes, attendance through a free kids ticket; 6-month retention rule for children |
| 1.44.0 | **Campus-scoped staff** (§12 part 2): Admin → Users "Limit to campuses"; `utils/campusScope.js` on every staff/supervisor route (and owner-or-staff routes), lists filtered, scanner names the other campus, church-wide events view/scan/door-only for scoped staff; route test `tests/campusScopeRoutes.test.js` |
@@ -59,10 +62,6 @@ encrypted settings unreadable or break existing installs.
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
| 1.32.0 | Early-bird ribbon on event cards |
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.37.4 | Deploy script: no tag = latest main; per-install `ecosystem.config.js` out of git (`ecosystem.config.example.js`) |
| 1.37.3 | Fix: the production build failed on unescaped quotes in a help text |
| 1.37.2 | Payment status checks (PayFast transaction history; job every 15 min; Payments → Reconcile → Waiting for payment), PayFast Test connection |
@@ -70,24 +69,37 @@ encrypted settings unreadable or break existing installs.
| 1.37.0 | PayFast (signed form, ITN checks), `ProviderCheckout` with amount checks, the "Pay online" page (`/pay/<id>`) where payers choose a provider |
| 1.36.0 | Payment provider interface (`backend/src/payments`) with Yoco behind it, `ProviderTransaction`, `Payment.provider`, Site settings → Payments (on/off and order per provider), R15 minimum |
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow (removed in 1.46.1), `scripts/deploy.sh` |
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
| 1.32.0 | Early-bird ribbon on event cards |
## Next up
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. **2.0.0** — AI helpers, and v1 removed. Every endpoint is on v2 since 1.51.16. Keep
answering the v1 webhook paths and the old links in sent messages (`src/v2/links.ts`) after
v1 goes: providers and old emails still use them.
2. **Servers run Node 24** (Joshua, 2026-10-29), so the backend may need Node 22+. Still to do:
move from ExcelTS — deprecated in favour of `documonster` by the same author (pre-1.0 in Oct
2026) — once documonster is stable. After 1.51.7 has run everywhere, the old `pgboss` schema
can be dropped (`DROP SCHEMA pgboss CASCADE;`).
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the
licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub
charges the real cost plus a markup % (a default and a per-licence override) and counts the cap
in charged rand. AI endpoints are `/api/v2` only.
1. **2.0.0** — AI helpers (§15), v1 removed, a migration guide, and marketing site/docs for
selling. Every endpoint is on v2 since 1.51.16. Keep answering the v1 webhook paths and the
old links in sent messages (`src/v2/links.ts`) after v1 goes: providers and old emails still
use them.
2. **AI rules** (decided 2026-10-29, roadmap §15): 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 charges the
real cost plus a markup % (a default and a per-licence override) and counts the cap in charged
rand. AI endpoints are `/api/v2` only. Check Claude Haiku 5.5's current prices before setting
the markup and caps.
3. **Clean-ups before or with 2.0:**
- Turn the React Compiler lint rules back to errors (see [Next.js 16](#nextjs-16-1515)) — every
screen is on v2 now, but they're still `warn` in `frontend/eslint.config.mjs`.
- `node-fetch` is still listed in `backend/package.json` though nothing in `src/` uses it —
remove it.
- Password hashing upgrade with rehash-on-login (roadmap §14) isn't done: still `bcryptjs`.
Decide whether it's in 2.0.
4. **Later:** move from ExcelTS — deprecated in favour of `documonster` by the same author
(pre-1.0 in Oct 2026) — once documonster is stable. After 1.51.7 has run everywhere, the old
`pgboss` schema can be dropped (`DROP SCHEMA pgboss CASCADE;`).
Languages (Afrikaans, §10) and Wallet passes (§7) are removed from scope (2026-10-28).
## Working on this repo
@@ -175,8 +187,8 @@ a test server (both done 2026-10-28).
up in its transaction history (`findPayments`); Yoco's Checkout API documents no lookup, so
Yoco checkouts are "can't check". Tests: `backend/tests/paymentStatus.test.js`.
- **Adding a provider:** write `providers/<id>.js` with the same interface, add it to
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/v2/webhooks/<id>` (in `src/v2/links.ts`; add the v1 path in `routes/webhookRoutes.js` only for providers already registered there)
(`routes/webhookRoutes.js`), and give it a settings card in
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/v2/webhooks/<id>` (in `src/v2/links.ts`; add the v1 path in `routes/webhookRoutes.js` only for providers already registered there),
and give it a settings card in
`frontend/src/components/admin/PaymentsTab.tsx`. New providers start switched off.
- `Payment.method` stays the wallet type (card, apple_pay…) because cashups bucket on it;
the provider is `Payment.provider`.
@@ -193,7 +205,8 @@ a test server (both done 2026-10-28).
- Bulk sends: `broadcastController`, `whatsappBroadcastController`, and `emailEventAttendees` /
`whatsappEventAttendees` in `eventController` take `kind`; scheduled payloads carry it.
- WhatsApp STOP: `handleIncomingMessage` in `whatsappController.js` (WAWP `message` event on
`/api/v2/webhooks/whatsapp?secret=…` — older set-ups use `/api/whatsapp/webhook`, which still works; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
`/api/v2/webhooks/whatsapp?secret=…` — older set-ups use `/api/whatsapp/webhook` or `/api/webhooks/whatsapp`, which run the same handler
since 1.51.16; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
- Frontend: `app/dashboard/user/privacy`, `app/unsubscribe`, `app/dashboard/admin/data-requests`,
`components/shared/NewsPrompt.tsx` (dashboard layout), `MessageKindPicker.tsx`.
- **Anything new that stores personal data** must be added to `anonymiseUser` and
@@ -466,7 +479,8 @@ a test server (both done 2026-10-28).
- A list that grows for ever must page on the server (`page`/`limit`, `total`, `pages`) or load
older items with a `before` cursor, and offer a search. `components/shared/Pager.tsx` renders
"21–40 of 312 · Prev · Next". Already paged: users, registrations (Manage registrations), events,
payments, audit log, consent/access logs, data requests, My payments.
payments, audit log, consent/access logs, data requests, My payments. (Paths below are the v1
names; each has a v2 successor in `SUCCESSORS`, `src/v2/deprecation.ts`.)
- **Audit-log actions:** a new `AdminAuditAction` needs a label in `ACTION_LABELS`
(`app/dashboard/admin/audit-log/page.tsx`) — the filter is built from that list.
- **Still loading everything (fine for now, watch as installs grow):** user pickers that call
@@ -547,8 +561,9 @@ a test server (both done 2026-10-28).
- 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.
mostly `useEffect(() => { load(); }, [...])`. The plan was to fix them as each screen moved onto
v2; every screen is on v2 now (1.51.15) but the rules are still `warn` — fix what's left and turn
them back to errors before or with 2.0. 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.
@@ -593,13 +608,14 @@ a test server (both done 2026-10-28).
## Event picker and webhook guide (1.50.2)
- **Choosing an event:** use `EventPicker` (`components/shared/EventPicker.tsx`), not a `<select>`.
Give it the whole list (`/api/events/all?includePast=true`); it opens on upcoming events, keeps
Give it the whole list (`fetchManagedEvents(token, { past: true })`, v2 `GET /events/manage`
since 1.51.7); it opens on upcoming events, keeps
past ones behind "Show past events", and searches all of them. `emptyLabel` adds a clearable
choice ("All events"), `graceHours` keeps just-ended events current (the scanner uses 12),
`describe` adds detail per row, and `defaultEventId(events)` picks the next upcoming one. In e2e,
`pickEvent(page, title)` from `e2e/fixtures.ts`.
- `/api/events/all` still returns full events (options, form, sessions) — fine for a few hundred;
a slim `?fields=` mode would help if a church gets into the thousands.
- The managed events list still returns full events (options, form, sessions) — fine for a few
hundred; a slim `?fields=` mode would help if a church gets into the thousands.
- **Webhook examples:** a new webhook event type needs an entry in `EXAMPLES` in
`utils/webhooks.js`; `tests/webhooks.test.js` fails if an example and `buildPayload` differ in
shape.
@@ -610,7 +626,6 @@ a test server (both done 2026-10-28).
into WAWP (events `message` + `session.status`), and only for senders WhatsApp identifies by
phone number — a sender known only by a "LID" is logged and ignored (they can still use the
link in the message).
- `/api/webhooks/whatsapp` runs the real WAWP handler since 1.51.16 (it was an empty stub).
- There's no log of individual messages sent, so "Download my data" can't include "messages
sent to you" yet.
+40 -45
View File
@@ -11,41 +11,35 @@ sold — trustworthy (POPIA), flexible (multi-day events, several payment
providers), maintainable (tests, CI, typed backend, versioned API), and with
**AI helpers** as the headline feature (§15).
**Status (2026-10-27):** all features in this document are approved. Shipped
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
phase A (sessions, late joining, per-session check-in, attendance register,
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page).
1.41.0 the Hub connection (licence, heartbeat, read-only mode, `/health`, install script) with Hub 1.0.0 (`joshua/events-hub`);
1.41.1–1.41.2 licence and job-time fixes; 1.41.3 renamed **Hope Events → Cross Events** (Hub 1.0.1:
Cross Events Hub); 1.42.0 **Request update** button instead of the deploy command, with Hub 1.1.0
(update requests); 1.42.1 the production Hub key built in. Later versions moved up by one
(2026-10-27). Multi-campus is split in two — 1.43.0 campuses, 1.44.0 campus-scoped staff — so
everything after moves up by one more (2026-10-27). 1.43.0 **campuses** shipped (settings, events
per campus, public filter and campus pages, home campus, filters, campus broadcasts and summaries);
1.44.0 **campus-scoped staff** shipped (Admin → Users "Limit to campuses", every staff route checked).
Wallet passes are removed from scope (2026-10-28), so everything after volunteer rosters moves
up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticker panel, rooms with
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
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**, 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** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest of event set-up on v2** and 1.51.9
**series, passes, deals and campuses on v2** 1.51.10 **managing registrations on v2**, 1.51.11 **money on v2** and 1.51.12 **tickets and
attendance on v2** 1.51.13 **kids and volunteers on v2**, 1.51.14 **people and messaging on v2** and 1.51.15
**privacy and admin on v2** and 1.51.16 **incoming webhooks and links on v2** shipped — every
endpoint is on v2. **Next: 2.0.0.**
**Status (2026-10-30):** all features in this document are approved, and everything before 2.0.0
has shipped (1.32.0 – 1.51.17). **Next: 2.0.0** — AI helpers (§15) and v1 removed. The full
version list is in [HANDOVER.md](./HANDOVER.md) and the [build order](#suggested-build-order) below.
- **Multi-day events** (§1): 1.33.0 phase A (sessions, late joining, per-session check-in,
attendance register, reminders, certificates); 1.35.0 phase B (pick sessions, per-session
capacity); 1.35.1 per-session waitlist, session picker at the door.
- **Platform:** 1.34.0 job queue (pg-boss), Playwright E2E tests and deploy script (the Gitea CI
workflow was removed in 1.46.1 — no runner).
- **Payments** (§2): 1.36.0 provider interface (Yoco behind it); 1.37.0 – 1.37.2 PayFast, "Pay
online", payment status checks. SnapScan and Zapper dropped (2026-10-23).
- **POPIA toolkit** (§3): 1.38.0 – 1.40.0.
- **Hub** (§13): 1.41.0 connection (licence, heartbeat, read-only mode, `/health`, install
script) with Hub 1.0.0; 1.41.3 renamed **Hope Events → Cross Events**; 1.42.0 **Request
update** with Hub 1.1.0; 1.42.1 production Hub key built in.
- **Multi-campus** (§12): 1.43.0 campuses; 1.44.0 campus-scoped staff.
- **Kids check-in** (§4) 1.45.0; **volunteer rosters** (§5) 1.46.0.
- **Event series and cloning** (§6): 1.47.0 copy an event, 1.47.1 series, 1.47.2 year-on-year
report, 1.47.3 season passes.
- **Outbound webhooks** (§8) 1.48.0.
- **Dashboard, palette, bulk actions, saved filters** (§9): 1.49.0 – 1.49.2.
- **Accessibility** (§11) 1.50.0; 1.50.1 long lists; 1.50.2 event picker and webhook guide.
- **TypeScript backend and `/api/v2`** (§14): 1.51.0 – 1.51.3 foundations, registrations and
payments, accounts and check-in, Express 5; 1.51.4 security and dependency updates; 1.51.5
Next.js 16; 1.51.6 command palette and ExcelTS; 1.51.7 – 1.51.16 every remaining area on v2 —
**every endpoint is on v2**.
- 1.51.17 security fix: forgot password and sign-in don't reveal whether an account exists.
Removed from scope (2026-10-28): Wallet passes (§7) and languages (§10).
**Repositories:**
@@ -833,13 +827,13 @@ keep adding to.
| Item | What |
|------|------|
| CI (Gitea Actions) | Lint, typecheck, Jest, build on every push; deploy on tag |
| Playwright E2E | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk |
| Job queue | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin |
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite |
| TypeScript backend | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend |
| API v2 | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition |
| Dependencies | Express 5, native `fetch` instead of `node-fetch`, password hashing upgrade with rehash-on-login |
| ~~CI (Gitea Actions)~~ | Added in 1.34.0, **removed in 1.46.1** (no machine to run a runner): lint, tests and deploy are done by hand |
| Playwright E2E ✅ | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk (1.34.0) |
| Job queue ✅ | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin (1.34.0; pg-boss 11 in 1.51.7) |
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite. Partly done (1.51.1 – 1.51.10); the handlers behind `v1Route` are a 2.x clean-up |
| TypeScript backend ✅ | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend (1.51.0) |
| API v2 ✅ | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition; every endpoint on v2 since 1.51.16 |
| Dependencies | Express 5 ✅ (1.51.3); native `fetch` ✅ (nothing uses `node-fetch` any more — it's still in `backend/package.json`, to remove); password hashing upgrade with rehash-on-login — **not done** (still `bcryptjs`), decide whether it's in 2.0 |
---
@@ -868,7 +862,8 @@ never saves, sends or publishes by itself.
- **One provider: Claude (Anthropic), model Claude Haiku 5.5** — the cheapest
current model ($0.10 per million input tokens, $0.50 per million output, for
prompts under 100K tokens; prices as of 2026-10). Good enough for drafting and
prompts under 100K tokens; prices as of 2026-10 — **check them against Anthropic's pricing
page before setting the markup and caps**). Good enough for drafting and
summarising; a helper can move to a bigger model later if quality needs it.
- **Rough cost:** a helper call is ~2,000–10,000 tokens in and ~500 out, so well under
US$0.002 per click — about R1.50–R3 per thousand clicks. Fixed instructions are
@@ -1004,7 +999,7 @@ income justify the hardware:
|-------|---------|----------|-----|
| 0 | 1.32.0 ✅ | **Early-bird ribbon** on event cards (shipped 2026-10-22) | Quick win |
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 2 | 1.34.0 ✅ | CI (removed in 1.46.1) + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
@@ -1014,7 +1009,7 @@ income justify the hardware:
| 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 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
| 11b | 1.51.4 ✅ – 1.51.16 ✅ | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **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 puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
| 11b | 1.51.4 ✅ – 1.51.16 ✅ | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **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 puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS`. Then 1.51.17, a security fix (forgot password and sign-in don't reveal accounts) | 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/`),
+10 -3
View File
@@ -307,9 +307,16 @@ test.describe("API v2", () => {
const users = (await (await request.get(`${API}/api/v2/users?page=1&limit=2`, { headers: sup })).json()).data;
expect(users).toMatchObject({ page: 1, limit: 2 });
// As v1: an unknown email is NOT_FOUND.
const forgot = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
expect((await forgot.json()).error.code).toBe("NOT_FOUND");
// Forgot-password doesn't say whether an account exists (1.51.17): the same answer either way.
const unknown = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
const known = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: seed().users.user.email } });
expect(unknown.status()).toBe(200);
expect(await unknown.json()).toEqual(await known.json());
// Nor does sign-in: an unknown email is refused like a wrong password.
const noAccount = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: "nobody-e2e@example.com", password: "Wrong-password-1" } });
const wrongPassword = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "Wrong-password-1" } });
expect(noAccount.status()).toBe(wrongPassword.status());
expect(await noAccount.json()).toEqual(await wrongPassword.json());
});
test("privacy and admin: settings, my data, the audit log, webhooks, backups", async ({ request }) => {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.16",
"version": "1.51.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.51.16",
"version": "1.51.17",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.51.16",
"version": "1.51.17",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -29239,7 +29239,7 @@
"Accounts"
],
"summary": "Email me a link to reset my password",
"description": "NOT_FOUND when no account has that email (as v1 does).\n\n**Who:** Anyone.",
"description": "Always the same answer, so it doesn't say whether an account has that email. Rate-limited like sign-in.\n\n**Who:** Anyone.",
"operationId": "postAuthPasswordForgot",
"parameters": [],
"requestBody": {
+1 -1
View File
@@ -4509,7 +4509,7 @@ export interface paths {
put?: never;
/**
* Email me a link to reset my password
* @description NOT_FOUND when no account has that email (as v1 does).
* @description Always the same answer, so it doesn't say whether an account has that email. Rate-limited like sign-in.
*
* **Who:** Anyone.
*/
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.51.16",
"version": "1.51.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.51.16",
"version": "1.51.17",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.51.16",
"version": "1.51.17",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",