Compare commits

..
3 Commits
Author SHA1 Message Date
joshua 254a5daebd Merge branch 'feat/api-v2-money' 2026-10-29 13:47:49 +02:00
joshuaandClaude Opus 5.5 28e31ffb99 Money on API v2 (1.51.11)
- v2: payments, refunds, donations, payment links and the pay page, providers, cash-ups,
  provider transactions, waitlist, sponsorship, consent, form responses, feedback.
- src/v2/viaV1.ts: v1Route() answers a v2 route with the v1 handler in-process.
- /docs/v2 opens collapsed with a filter (Swagger UI stopped drawing past ~130 endpoints).
- Frontend: every money screen on v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 13:47:49 +02:00
joshuaandClaude Opus 5.5 6ee1438e23 WIP 1.51.11: money on v2 via v1 handlers (viaV1 adapter)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 13:43:36 +02:00
46 changed files with 10394 additions and 168 deletions
+23
View File
@@ -7,6 +7,29 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.51.11] - 2026-10-29
### Added
- **API v2 — money:** payments (`/payments`, `/payments/{id}`, receipts, refunds, donations
assigned to registrations, stats, providers and their settings, open checkouts), payment links
(`/registrations/{id}/payment-link` (+ `/send`), `/payment-links/{id}` — the pay page),
`/donations/checkout`, `/me/payments`; cash-ups (`/events/{id}/cashup` and its counts, draft, close
and reopen, `/cashup-lines/{id}/pending`, `/cashups/audit`); provider transactions; the waitlist
(join, mine, offers by link, the event's list, offer/move/remove); sponsorship; parental consent
(the event's list, medical notes, reminders, the consent form by link); `/form-responses`; and
feedback (set-up, send now, the summary, the form by link).
- **`src/v2/viaV1.ts`:** `v1Route()` — a v2 route answered by the v1 handler, in-process, behind
v2's access checks, validation and docs. v1 JSON becomes `data`; v1 errors become v2 errors with
v1's `code` as `reason`; piped files become downloads.
- The v2 reference (`/docs/v2`) opens with its sections collapsed and has a filter box — with
more than about 130 endpoints Swagger UI stopped drawing the expanded list.
### Changed
- Every money screen (payments, at the door, cash-up, consent, waitlist, feedback, forms, the pay
and donate pages, the user dashboard) uses v2.
## [1.51.10] - 2026-10-29
### Added
+6
View File
@@ -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.11
- **More groundwork for 2.0.** Payments, cash-ups, the waitlist, sponsorship, consent forms and
feedback now run on the new version of the server's connection to the app. They work just as
before.
## 1.51.10
- **More groundwork for 2.0.** Registrations — at the door, in the dashboards, attendee forms and
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.51.10",
"version": "1.51.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.51.10",
"version": "1.51.11",
"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.10",
"version": "1.51.11",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+55
View File
@@ -46,6 +46,61 @@ const SUCCESSORS: Successor[] = [
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
// 1.51.11: money (payments, cash-ups, provider transactions, waitlist, sponsorship, consent, forms, feedback)
{ method: 'GET', v1: /^\/api\/payments\/providers$/, v2: () => '/api/v2/payments/providers' },
{ method: 'GET', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
{ method: 'PUT', v1: /^\/api\/payments\/providers\/settings$/, v2: () => '/api/v2/payments/providers/settings' },
{ method: 'GET', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
{ method: 'POST', v1: /^\/api\/payments\/pay\/([^/]+)$/, v2: m => `/api/v2/payment-links/${m[1]}` },
{ method: 'GET', v1: /^\/api\/payments\/checkouts\/open$/, v2: () => '/api/v2/payments/open-checkouts' },
{ method: 'POST', v1: /^\/api\/payments\/checkouts\/check$/, v2: () => '/api/v2/payments/open-checkouts/check' },
{ method: 'POST', v1: /^\/api\/payments\/(yoco-)?checkout\/send$/, v2: () => '/api/v2/registrations/{id}/payment-link' },
{ method: 'GET', v1: /^\/api\/payments\/mypayments$/, v2: () => '/api/v2/me/payments' },
{ method: 'GET', v1: /^\/api\/payments\/admin\/stats$/, v2: () => '/api/v2/payments/stats' },
{ method: 'GET', v1: /^\/api\/payments\/registration\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/payments` },
{ method: 'GET', v1: /^\/api\/payments\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/payments` },
{ method: 'PUT', v1: /^\/api\/payments\/assign-donation$/, v2: () => '/api/v2/payments/donations/assign' },
{ method: 'POST', v1: /^\/api\/payments\/unassign-donation$/, v2: () => '/api/v2/payments/donations/unassign' },
{ method: 'POST', v1: /^\/api\/payments\/refund$/, v2: () => '/api/v2/payments/refunds' },
{ method: 'POST', v1: /^\/api\/payments\/([^/]+)\/send-receipt$/, v2: m => `/api/v2/payments/${m[1]}/receipt` },
{ method: 'GET', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
{ method: 'POST', v1: /^\/api\/payments\/?$/, v2: () => '/api/v2/payments' },
{ method: 'GET', v1: /^\/api\/payments\/([^/]+)$/, v2: m => `/api/v2/payments/${m[1]}` },
{ method: 'GET', v1: /^\/api\/cashups\/audit$/, v2: () => '/api/v2/cashups/audit' },
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup` },
{ method: 'GET', v1: /^\/api\/cashups\/event\/([^/]+)\/cash-by-user$/, v2: m => `/api/v2/events/${m[1]}/cashup/cash-by-user` },
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/person-cash\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/cashup/person-cash/${m[2]}` },
{ method: 'PUT', v1: /^\/api\/cashups\/event\/([^/]+)\/draft$/, v2: m => `/api/v2/events/${m[1]}/cashup/draft` },
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/close$/, v2: m => `/api/v2/events/${m[1]}/cashup/close` },
{ method: 'POST', v1: /^\/api\/cashups\/event\/([^/]+)\/reopen$/, v2: m => `/api/v2/events/${m[1]}/cashup/reopen` },
{ method: 'POST', v1: /^\/api\/cashups\/lines\/([^/]+)\/pending$/, v2: m => `/api/v2/cashup-lines/${m[1]}/pending` },
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/?$/, v2: () => '/api/v2/provider-transactions' },
{ method: 'GET', v1: /^\/api\/(provider|yoco)-transactions\/unreconciled$/, v2: () => '/api/v2/provider-transactions/unreconciled' },
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/reconcile$/, v2: m => `/api/v2/provider-transactions/${m[1]}/reconcile` },
{ method: 'POST', v1: /^\/api\/(?:provider|yoco)-transactions\/([^/]+)\/ignore$/, v2: m => `/api/v2/provider-transactions/${m[1]}/ignore` },
{ method: 'POST', v1: /^\/api\/waitlist\/?$/, v2: () => '/api/v2/waitlist' },
{ method: 'GET', v1: /^\/api\/waitlist\/mine$/, v2: () => '/api/v2/me/waitlist' },
{ method: 'GET', v1: /^\/api\/waitlist\/claim\/([^/]+)$/, v2: m => `/api/v2/waitlist/claims/${m[1]}` },
{ method: 'GET', v1: /^\/api\/waitlist\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/waitlist` },
{ method: 'POST', v1: /^\/api\/waitlist\/([^/]+)\/(offer|move|remove)$/, v2: m => `/api/v2/waitlist/${m[1]}/${m[2]}` },
{ method: 'DELETE', v1: /^\/api\/waitlist\/([^/]+)$/, v2: m => `/api/v2/waitlist/${m[1]}` },
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/sponsorship` },
{ method: 'GET', v1: /^\/api\/sponsorships\/event\/([^/]+)\/pool$/, v2: m => `/api/v2/events/${m[1]}/sponsorship/pool` },
{ method: 'POST', v1: /^\/api\/sponsorships\/checkout$/, v2: () => '/api/v2/sponsorships/checkout' },
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/consent` },
{ method: 'GET', v1: /^\/api\/consent\/events\/([^/]+)\/medical$/, v2: m => `/api/v2/events/${m[1]}/consent/medical` },
{ method: 'POST', v1: /^\/api\/consent\/events\/([^/]+)\/(resend-pending|rerequest)$/, v2: m => `/api/v2/events/${m[1]}/consent/${m[2]}` },
{ method: 'POST', v1: /^\/api\/consent\/requests\/([^/]+)\/resend$/, v2: m => `/api/v2/consent-requests/${m[1]}/resend` },
{ method: 'GET', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
{ method: 'POST', v1: /^\/api\/consent\/([^/]+)$/, v2: m => `/api/v2/consent/${m[1]}` },
{ method: 'GET', v1: /^\/api\/forms\/responses$/, v2: () => '/api/v2/form-responses' },
{ method: 'GET', v1: /^\/api\/feedback\/summary$/, v2: () => '/api/v2/feedback/summary' },
{ method: 'GET', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
{ method: 'POST', v1: /^\/api\/feedback\/public\/([^/]+)$/, v2: m => `/api/v2/feedback-forms/${m[1]}` },
{ method: 'GET', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
{ method: 'PUT', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
{ method: 'DELETE', v1: /^\/api\/feedback\/event\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}/feedback` },
{ method: 'POST', v1: /^\/api\/feedback\/event\/([^/]+)\/send-now$/, v2: m => `/api/v2/events/${m[1]}/feedback/send-now` },
// 1.51.10: managing registrations
{ method: 'GET', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations/full' },
+2 -1
View File
@@ -14,6 +14,7 @@ import './resources/events';
import './resources/me';
import './resources/registrations';
import './resources/registrationsManage';
import './resources/money';
import './resources/checkin';
import './resources/auth';
@@ -86,7 +87,7 @@ const DOCS_HTML = `<!doctype html>
sessionStorage.setItem('v2docs_token', t);
if (params.has('token')) history.replaceState(null, '', location.pathname);
document.getElementById('login').hidden = true;
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false });
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false, docExpansion: 'none', filter: true });
ui.preauthorizeApiKey('bearerAuth', t);
}).catch(function (e) {
document.getElementById('login').hidden = false;
+310
View File
@@ -0,0 +1,310 @@
/**
* Money on v2 (1.51.11): payments and refunds, payment links and providers, cash-ups, provider
* transactions, the waitlist, sponsorship, consent, attendee form answers and feedback. Each
* route runs the v1 handler (viaV1.ts) behind the same access and campus checks as v1.
*/
import { z } from 'zod';
import { id, idParams } from '../schemas';
import { listData, v1Route } from '../viaV1';
/* eslint-disable @typescript-eslint/no-explicit-any -- v1 handlers and middleware are JavaScript */
const rateLimit = require('express-rate-limit') as (opts: object) => any;
const cs = require('../../utils/campusScope') as {
scopeEvent(opts?: { need?: 'view' | 'manage'; resolve?: string; from?: (req: any) => unknown; ownerOk?: boolean }): any;
scopeBody(targets: [string, string][], opts?: { need?: 'view' | 'manage'; orphan?: 'pass' | 'deny' }): any;
scopeList: any;
unscopedOnly: any;
};
const pay = require('../../controllers/paymentController');
const providers = require('../../controllers/paymentProvidersController');
const cashup = require('../../controllers/cashupController');
const ptx = require('../../controllers/providerTransactionsController');
const waitlist = require('../../controllers/waitlistController');
const sponsor = require('../../controllers/sponsorshipController');
const consent = require('../../controllers/consentController');
const forms = require('../../controllers/formController');
const feedback = require('../../controllers/feedbackController');
/** Optional text filters, passed to v1 as they come. */
const filters = (...names: string[]) => z.looseObject(Object.fromEntries(names.map(n => [n, z.string().max(4000).optional()])));
const paymentFilters = filters('page', 'limit', 'userId', 'from', 'donations', 'method', 'kind', 'startDate', 'endDate');
const tokenParams = z.object({ token: z.string().min(1).max(200) });
const asEvent = (ctx: any) => ({ params: { ...ctx.req.params, eventId: ctx.params.id } });
// Links in messages are credentials, so guessing stays slow (as on v1).
const linkLimiter = () => rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
const SCOPE_LIST = 'scopeList (the handler filters by listWhere)';
const UNSCOPED = 'unscopedOnly (admins and unscoped supervisors)';
// ─── Payments ────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/payments/providers', tag: 'Payments', summary: 'The payment providers switched on',
access: 'public', v1: providers.listPaymentProviders,
});
v1Route({
method: 'get', path: '/payments/providers/settings', tag: 'Payments', summary: 'Payment provider settings',
description: 'Admins. Keys are masked.', access: 'admin', v1: providers.getPaymentProviderSettings,
});
v1Route({
method: 'put', path: '/payments/providers/settings', tag: 'Payments', summary: 'Change payment provider settings',
access: 'admin', body: z.looseObject({}), v1: providers.updatePaymentProviderSettings,
});
v1Route({
method: 'get', path: '/payments/open-checkouts', tag: 'Payments', summary: 'Checkouts started but not finished',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: providers.listOpenCheckouts,
});
v1Route({
method: 'post', path: '/payments/open-checkouts/check', tag: 'Payments', summary: 'Ask the provider about open checkouts',
description: 'Records any that were paid (for when a webhook was missed).',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], body: z.looseObject({}), v1: providers.checkCheckoutStatus,
});
v1Route({
method: 'get', path: '/payments/stats', tag: 'Payments', summary: 'Payment totals', access: 'admin', query: paymentFilters, v1: pay.getPaymentStats,
});
v1Route({
method: 'get', path: '/payments', tag: 'Payments', summary: 'Every payment',
description: 'For the events you may see. `page` and `limit` page it (otherwise everything); see v1 for the filters it takes.',
access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: paymentFilters, v1: pay.getPayments,
});
v1Route({
method: 'post', path: '/payments', tag: 'Payments', summary: 'Record a payment',
description: 'Cash, card, EFT or other, against a registration or (a donation) an event. Paid in full issues and sends the tickets.',
access: 'supervisor', campusScope: 'scopeBody(registrationId, eventId; deny orphans)', status: 201,
middleware: [cs.scopeBody([['registrationId', 'registration'], ['eventId', 'event']], { orphan: 'deny' })],
body: z.looseObject({ registrationId: id.nullish(), eventId: id.nullish(), amount: z.union([z.number(), z.string()]), method: z.string().max(20).optional() }),
v1: pay.createPayment,
});
v1Route({
method: 'post', path: '/payments/refunds', tag: 'Payments', summary: 'Refund a payment',
access: 'supervisor', campusScope: 'scopeBody(paymentId, registrationId; manage, deny orphans)', status: 201,
middleware: [cs.scopeBody([['paymentId', 'payment'], ['registrationId', 'registration']], { need: 'manage', orphan: 'deny' })],
body: z.looseObject({ paymentId: id.optional(), registrationId: id.optional() }), v1: pay.createRefund,
});
v1Route({
method: 'post', path: '/payments/donations/assign', tag: 'Payments', summary: 'Put a donation towards a registration',
access: 'supervisor', campusScope: 'scopeBody(paymentId deny orphans; registrationId)',
middleware: [cs.scopeBody([['paymentId', 'payment']], { orphan: 'deny' }), cs.scopeBody([['registrationId', 'registration']])],
body: z.looseObject({ paymentId: id, registrationId: id }), v1: pay.assignDonationToRegistration,
});
v1Route({
method: 'post', path: '/payments/donations/unassign', tag: 'Payments', summary: 'Take a donation back off a registration',
access: 'supervisor', campusScope: 'scopeBody(legId; deny orphans)', middleware: [cs.scopeBody([['legId', 'payment']], { orphan: 'deny' })],
body: z.looseObject({ legId: id }), v1: pay.unassignDonationFromRegistration,
});
v1Route({
method: 'post', path: '/donations/checkout', tag: 'Payments', summary: 'Give to an event',
description: 'Starts a checkout for a donation of `amount` to `eventId`; answers where to send the person (`redirectUrl`).',
access: 'user', body: z.looseObject({ eventId: id, amount: z.union([z.number(), z.string()]) }), v1: pay.createCheckout,
});
v1Route({
method: 'get', path: '/me/payments', tag: 'Me', summary: 'My payments', access: 'user', query: paymentFilters, v1: pay.getUserPayments,
});
v1Route({
method: 'get', path: '/payments/:id', tag: 'Payments', summary: 'One payment',
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams, v1: pay.getPaymentById,
});
v1Route({
method: 'post', path: '/payments/:id/receipt', tag: 'Payments', summary: 'Send the receipt again',
access: 'user', campusScope: 'scopeEvent(payment, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'payment', ownerOk: true })], params: idParams,
body: z.looseObject({ channel: z.string().max(20).optional() }), v1: pay.sendReceipt,
});
v1Route({
method: 'get', path: '/registrations/:id/payments', tag: 'Payments', summary: 'A registration\'s payments',
access: 'user', campusScope: 'scopeEvent(registration, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'registration', ownerOk: true })], params: idParams,
response: listData, map: ctx => ({ params: { registrationId: ctx.params.id } }), v1: pay.getPaymentsByRegistration,
});
v1Route({
method: 'post', path: '/registrations/:id/payment-link', tag: 'Payments', summary: 'Make a link to pay',
description: 'Supervisors: a checkout for `amount` (part of what\x27s owed) for someone else to pay. Answers `redirectUrl` (the link) — or `priceUpdated` when an early-bird price ran out.',
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.looseObject({ amount: z.union([z.number(), z.string()]).optional() }), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.createCheckout,
});
v1Route({
method: 'post', path: '/registrations/:id/payment-link/send', tag: 'Payments', summary: 'Send someone a link to pay',
description: 'By email and/or WhatsApp, for what\'s still owing.',
access: 'supervisor', campusScope: 'scopeEvent(registration)', middleware: [cs.scopeEvent({ resolve: 'registration' })], params: idParams,
body: z.looseObject({}), map: ctx => ({ body: { ...(ctx.req.body || {}), registrationId: ctx.params.id } }), v1: pay.sendPaymentLink,
});
v1Route({
method: 'get', path: '/events/:id/payments', tag: 'Payments', summary: 'An event\'s payments',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: pay.getPaymentsByEvent,
});
v1Route({
method: 'get', path: '/payment-links/:id', tag: 'Payments', summary: 'A pay page',
description: 'What a payment link shows: what\'s owed and the providers to pay with. The id in the link is the credential.',
access: 'public', params: idParams, v1: providers.getPayPage,
});
v1Route({
method: 'post', path: '/payment-links/:id', tag: 'Payments', summary: 'Pay from a pay page',
description: 'Starts a checkout with the chosen provider; answers where to send the person.',
access: 'public', params: idParams, body: z.looseObject({ provider: z.string().max(40).optional() }), v1: providers.choosePayProvider,
});
// ─── Cash-ups ────────────────────────────────────────────────────────────────
const cashupBase = '/events/:id/cashup';
v1Route({
method: 'get', path: '/cashups/audit', tag: 'Cash-ups', summary: 'Cash-up history', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'from', 'to'), v1: cashup.getCashupAudit,
});
v1Route({
method: 'get', path: cashupBase, tag: 'Cash-ups', summary: 'An event\'s cash-up',
description: 'Income by method, costs, what each drawer should hold, and the saved draft.',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getEventCashup,
});
v1Route({
method: 'get', path: `${cashupBase}/cash-by-user`, tag: 'Cash-ups', summary: 'Cash taken by each person',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: cashup.getCashByRecordedUser,
});
v1Route({
method: 'put', path: `${cashupBase}/person-cash/:userId`, tag: 'Cash-ups', summary: 'Count one person\'s cash',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: z.object({ id, userId: id }),
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id, userId: ctx.params.userId } }), v1: cashup.savePersonCash,
});
v1Route({
method: 'put', path: `${cashupBase}/draft`, tag: 'Cash-ups', summary: 'Save the cash-up as a draft',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.saveEventCashupDraft,
});
v1Route({
method: 'post', path: `${cashupBase}/close`, tag: 'Cash-ups', summary: 'Close the event',
description: 'Admins. Nothing about the event\'s money can change until it\'s reopened.',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.closeEvent,
});
v1Route({
method: 'post', path: `${cashupBase}/reopen`, tag: 'Cash-ups', summary: 'Reopen a closed event',
access: 'admin', params: idParams, body: z.looseObject({}), map: asEvent, v1: cashup.reopenEvent,
});
v1Route({
method: 'post', path: '/cashup-lines/:id/pending', tag: 'Cash-ups', summary: 'Settle a pending cash-up line',
access: 'admin', params: idParams, body: z.looseObject({}), map: ctx => ({ params: { lineId: ctx.params.id } }), v1: cashup.resolvePending,
});
// ─── Provider transactions ───────────────────────────────────────────────────
v1Route({
method: 'get', path: '/provider-transactions', tag: 'Payments', summary: 'Transactions from Yoco and PayFast',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], query: filters('provider'), v1: ptx.getAllProviderTransactions,
});
v1Route({
method: 'get', path: '/provider-transactions/unreconciled', tag: 'Payments', summary: 'Provider transactions not matched yet',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], v1: ptx.getUnreconciledProviderTransactions,
});
v1Route({
method: 'post', path: '/provider-transactions/:id/reconcile', tag: 'Payments', summary: 'Match a provider transaction to a registration',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.reconcileProviderTransaction,
});
v1Route({
method: 'post', path: '/provider-transactions/:id/ignore', tag: 'Payments', summary: 'Ignore a provider transaction',
access: 'supervisor', campusScope: UNSCOPED, middleware: [cs.unscopedOnly], params: idParams, body: z.looseObject({}), v1: ptx.ignoreProviderTransaction,
});
// ─── Waitlist ────────────────────────────────────────────────────────────────
v1Route({
method: 'post', path: '/waitlist', tag: 'Waitlist', summary: 'Join a sold-out event\'s waitlist',
description: 'Signed in, or with a name and email or phone. When places free up, people are offered them in order.',
access: 'optional', status: 201, body: z.looseObject({ eventId: id }), v1: waitlist.joinWaitlist,
});
v1Route({ method: 'get', path: '/me/waitlist', tag: 'Me', summary: 'Waitlists I\'m on', access: 'user', v1: waitlist.myWaitlist });
v1Route({
method: 'get', path: '/waitlist/claims/:token', tag: 'Waitlist', summary: 'A waitlist offer',
description: 'What an offer link shows (the token is the credential). Register with `waitlistToken` to take it.',
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: waitlist.claimInfo,
});
v1Route({
method: 'get', path: '/events/:id/waitlist', tag: 'Waitlist', summary: 'An event\'s waitlist',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, query: filters('all'), map: asEvent, v1: waitlist.eventWaitlist,
});
for (const [action, summary, handler] of [['offer', 'Offer someone their places now', waitlist.offerManually], ['move', 'Move someone up or down the waitlist', waitlist.moveEntry], ['remove', 'Take someone off the waitlist', waitlist.removeEntry]] as const) {
v1Route({
method: 'post', path: `/waitlist/:id/${action}`, tag: 'Waitlist', summary,
access: 'supervisor', campusScope: 'scopeEvent(waitlistEntry)', middleware: [cs.scopeEvent({ resolve: 'waitlistEntry' })], params: idParams, body: z.looseObject({}), v1: handler,
});
}
v1Route({ method: 'delete', path: '/waitlist/:id', tag: 'Waitlist', summary: 'Leave a waitlist', access: 'user', params: idParams, v1: waitlist.leaveWaitlist });
// ─── Sponsorship ─────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/events/:id/sponsorship', tag: 'Sponsorship', summary: 'Sponsoring places at an event',
description: 'Whether it\'s on, and the suggested amounts.', access: 'public', params: idParams, map: asEvent, v1: sponsor.getEventSponsorship,
});
v1Route({
method: 'get', path: '/events/:id/sponsorship/pool', tag: 'Sponsorship', summary: 'The sponsorship pool',
description: 'What\'s been given and what\'s been used.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: sponsor.getPool,
});
v1Route({
method: 'post', path: '/sponsorships/checkout', tag: 'Sponsorship', summary: 'Give towards places',
description: 'Starts a checkout; answers where to send the person.', access: 'optional', body: z.looseObject({ eventId: id }), v1: sponsor.createSponsorCheckout,
});
// ─── Consent ─────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/events/:id/consent', tag: 'Consent', summary: 'Parental consent for an event',
access: 'staff', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.listEventConsent,
});
v1Route({
method: 'get', path: '/events/:id/consent/medical', tag: 'Consent', summary: 'Medical notes from consent forms',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: consent.medicalList,
});
v1Route({
method: 'post', path: '/events/:id/consent/resend-pending', tag: 'Consent', summary: 'Remind everyone who hasn\'t answered',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, body: z.looseObject({}), map: asEvent, v1: consent.resendPending,
});
v1Route({
method: 'post', path: '/events/:id/consent/rerequest', tag: 'Consent', summary: 'Ask for consent again',
description: 'After the consent wording changed.', access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams,
body: z.looseObject({}), map: ctx => ({ params: { eventId: ctx.params.id }, body: ctx.req.body }), v1: consent.rerequestConsent,
});
v1Route({
method: 'post', path: '/consent-requests/:id/resend', tag: 'Consent', summary: 'Send a consent request again',
access: 'user', campusScope: 'scopeEvent(consentRequest, owner-ok)', middleware: [cs.scopeEvent({ resolve: 'consentRequest', ownerOk: true })], params: idParams,
body: z.looseObject({}), v1: consent.resendRequest,
});
v1Route({
method: 'get', path: '/consent/:token', tag: 'Consent', summary: 'A consent form',
description: 'What the link in a consent request shows (the token is the parent\'s credential).',
access: 'public', middleware: [linkLimiter()], params: tokenParams, v1: consent.getConsent,
});
v1Route({
method: 'post', path: '/consent/:token', tag: 'Consent', summary: 'Answer a consent form',
access: 'public', middleware: [linkLimiter()], params: tokenParams, body: z.looseObject({}), v1: consent.answerConsent,
});
// ─── Attendee form answers ───────────────────────────────────────────────────
v1Route({
method: 'get', path: '/form-responses', tag: 'Registrations', summary: 'Attendee form answers',
description: 'For an event (`eventId`) or a registration (`registrationId`), for events you may see.',
access: 'staff', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventId', 'registrationId', 'userId', 'cursor'), v1: forms.listFormResponses,
});
// ─── Feedback ────────────────────────────────────────────────────────────────
v1Route({
method: 'get', path: '/feedback/summary', tag: 'Feedback', summary: 'Feedback across events', access: 'supervisor', campusScope: SCOPE_LIST, middleware: [cs.scopeList], query: filters('eventIds'), v1: feedback.getSummary,
});
v1Route({
method: 'get', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'A feedback form',
description: 'What the link sent after an event shows (the token is the credential).', access: 'public', params: tokenParams, v1: feedback.getPublicForm,
});
v1Route({
method: 'post', path: '/feedback-forms/:token', tag: 'Feedback', summary: 'Answer a feedback form',
access: 'public', params: tokenParams, body: z.looseObject({}), v1: feedback.submitPublicForm,
});
v1Route({
method: 'get', path: '/events/:id/feedback', tag: 'Feedback', summary: 'An event\'s feedback set-up and answers',
access: 'supervisor', campusScope: 'scopeEvent(view)', middleware: [cs.scopeEvent()], params: idParams, map: asEvent, v1: feedback.getEventFeedback,
});
v1Route({
method: 'put', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Set up an event\'s feedback form',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.saveEventFeedback,
});
v1Route({
method: 'delete', path: '/events/:id/feedback', tag: 'Feedback', summary: 'Remove an event\'s feedback form',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, map: asEvent, v1: feedback.deleteEventFeedback,
});
v1Route({
method: 'post', path: '/events/:id/feedback/send-now', tag: 'Feedback', summary: 'Send the feedback form now',
access: 'supervisor', campusScope: 'scopeEvent(manage)', middleware: [cs.scopeEvent({ need: 'manage' })], params: idParams, body: z.looseObject({}), map: asEvent, v1: feedback.sendNow,
});
+2 -2
View File
@@ -105,7 +105,7 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
throw new ApiError('INTERNAL', 'Something went wrong on the server.');
}
if (def.file) {
const f = (checked.data as { data: { contentType: string; filename: string; body: string } }).data;
const f = (checked.data as { data: { contentType: string; filename: string; body: string | Buffer } }).data;
res.setHeader('Content-Type', f.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${f.filename.replace(/"/g, '')}"`);
res.status(def.status ?? 200).send(f.body);
@@ -121,4 +121,4 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
}
/** The `response` of a `file` route: what the handler hands back to be sent as a download. */
export const fileResponse = z.object({ data: z.object({ contentType: z.string(), filename: z.string(), body: z.string() }) });
export const fileResponse = z.object({ data: z.object({ contentType: z.string(), filename: z.string(), body: z.union([z.string(), z.instanceof(Buffer)]) }) });
+166
View File
@@ -0,0 +1,166 @@
/**
* v2 routes that run a v1 handler in-process (1.51.11 –). The handler is the one v1 runs, so
* both versions behave the same; v2 adds its own path, validation, docs and response shape.
*
* - A v1 answer of 2xx becomes `{ data: <the v1 JSON> }` (or a file download, for `file` routes).
* - A v1 error (`{ message, code? }` with 4xx/5xx) becomes the v2 error: the status picks `code`,
* v1's `code` becomes `reason`.
*
* The handlers are still JavaScript; moving each area into a TypeScript service (as 1.51.1 –
* 1.51.10 did) can follow later without changing the v2 API.
*/
import { Writable } from 'stream';
import { z } from 'zod';
import type { Request, RequestHandler } from 'express';
import { defineRoute, fileResponse, type Access, type Ctx } from './route';
/* eslint-disable @typescript-eslint/no-explicit-any -- requests and v1 answers are passed through */
type Parts = { params?: Record<string, unknown>; query?: Record<string, unknown>; body?: unknown };
type V1Answer = { status: number; json?: unknown; body?: Buffer; headers: Record<string, string>; redirect?: string };
/** A response that records what a v1 handler sends (JSON, `send`, a piped stream or a redirect). */
class RecordingResponse extends Writable {
statusCode = 200;
headersSent = false;
locals: Record<string, unknown> = {};
readonly headers: Record<string, string> = {};
private chunks: Buffer[] = [];
json?: (v: unknown) => this;
answer?: V1Answer;
private done!: (a: V1Answer) => void;
readonly finished: Promise<V1Answer>;
constructor() {
super();
this.finished = new Promise(resolve => { this.done = resolve; });
this.json = (v: unknown) => this.finishWith({ json: JSON.parse(JSON.stringify(v ?? null)) });
}
override _write(chunk: any, _enc: BufferEncoding, cb: () => void) { this.chunks.push(Buffer.from(chunk)); cb(); }
override _final(cb: () => void) { this.finishWith({}); cb(); }
private finishWith(extra: Partial<V1Answer>): this {
if (this.answer) return this;
this.headersSent = true;
this.answer = { status: this.statusCode, headers: this.headers, body: Buffer.concat(this.chunks), ...extra };
this.done(this.answer);
return this;
}
status(code: number) { this.statusCode = code; return this; }
sendStatus(code: number) { this.statusCode = code; return this.finishWith({}); }
setHeader(name: string, value: unknown) { this.headers[name.toLowerCase()] = String(value); return this; }
getHeader(name: string) { return this.headers[name.toLowerCase()]; }
removeHeader(name: string) { delete this.headers[name.toLowerCase()]; }
set(name: string | Record<string, unknown>, value?: unknown) {
if (typeof name === 'object') for (const [k, v] of Object.entries(name)) this.setHeader(k, v);
else this.setHeader(name, value);
return this;
}
header(name: string, value?: unknown) { return this.set(name, value); }
type(t: string) { return this.setHeader('content-type', t.includes('/') ? t : t === 'json' ? 'application/json' : `text/${t}`); }
attachment(filename?: string) { return filename ? this.setHeader('content-disposition', `attachment; filename="${filename}"`) : this; }
send(body?: unknown) {
if (body !== null && typeof body === 'object' && !Buffer.isBuffer(body)) return this.json!(body);
if (body !== undefined) this.chunks.push(Buffer.isBuffer(body) ? body : Buffer.from(String(body)));
return this.finishWith({});
}
// A handler that ends without writing anything still answers.
override end(...args: any[]) {
const [chunk] = args;
if (chunk !== undefined && typeof chunk !== 'function') this.chunks.push(Buffer.from(chunk));
this.finishWith({});
return this;
}
redirect(a: number | string, b?: string) {
const url = typeof a === 'string' ? a : b!;
this.statusCode = typeof a === 'number' ? a : 302;
return this.finishWith({ redirect: url });
}
}
/**
* Runs v1 handlers (and middleware) for a v2 request. `parts` replaces the request's params,
* query or body (e.g. v2 `:id` given to v1 as `:eventId`); everything else (the user, headers,
* campus scope, read-only state) is the real request's.
*/
export async function runV1(handlers: RequestHandler | RequestHandler[], req: Request, parts: Parts = {}): Promise<V1Answer> {
const fake = Object.create(req) as Request;
for (const key of ['params', 'query', 'body'] as const) {
if (parts[key] !== undefined) Object.defineProperty(fake, key, { value: parts[key], writable: true, configurable: true, enumerable: true });
}
const res = new RecordingResponse();
const chain = Array.isArray(handlers) ? handlers : [handlers];
const step = (i: number): Promise<void> => new Promise((resolve, reject) => {
let advanced = false;
const next = (err?: unknown) => {
advanced = true;
if (err) return reject(err);
if (i + 1 < chain.length) step(i + 1).then(resolve, reject);
else resolve();
};
Promise.resolve(chain[i](fake, res as any, next)).then(() => { if (!advanced) resolve(); }, reject);
});
await Promise.race([step(0).then(() => res.finished), res.finished]);
return res.answer!;
}
/** A v1 error answer as something v2's error handler understands. */
function v1Error(a: V1Answer): Error {
const j = (a.json ?? {}) as { message?: unknown; error?: unknown; code?: unknown };
const text = typeof j.message === 'string' ? j.message : typeof j.error === 'string' ? j.error : a.body?.length ? a.body.toString('utf8').slice(0, 500) : 'Request failed.';
const e = new Error(text) as Error & { statusCode: number; code?: string };
e.statusCode = a.status;
if (typeof j.code === 'string') e.code = j.code;
return e;
}
/** `{ data: <anything> }` — for v1 answers documented by their description. */
export const anyData = z.object({ data: z.unknown() });
export const listData = z.object({ data: z.array(z.looseObject({})) });
export const objectData = z.object({ data: z.looseObject({}) });
type V1RouteDef = {
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
path: string;
tag: string;
summary: string;
description?: string;
access: Access;
campusScope?: string;
middleware?: RequestHandler[];
params?: z.ZodType;
query?: z.ZodType;
body?: z.ZodType;
response?: z.ZodType;
status?: number;
file?: { contentType: string };
upload?: { field: string; description: string };
/** The v1 handler (or middleware chain ending in one). */
v1: RequestHandler | RequestHandler[];
/**
* What v1 gets as params/query/body. By default: the v2 params, the raw query and the raw body
* (validated by `query`/`body` when given, but passed as sent so v1 sees what it always saw).
*/
map?: (ctx: Ctx<any, any, any>) => Parts;
};
/** A v2 route answered by a v1 handler. */
export function v1Route(def: V1RouteDef) {
const { v1, map, response, ...rest } = def;
return defineRoute({
...rest,
response: def.file ? fileResponse : (response ?? anyData),
async handler(ctx) {
const parts: Parts = { params: ctx.req.params, query: ctx.req.query as any, body: ctx.req.body, ...(map ? map(ctx) : {}) };
const a = await runV1(v1, ctx.req, parts);
if (a.status >= 400) throw v1Error(a);
if (a.redirect) return { data: { redirectUrl: a.redirect } } as any;
if (def.file) {
const name = /filename="?([^";]+)"?/.exec(a.headers['content-disposition'] || '')?.[1] || 'download';
return { data: { contentType: a.headers['content-type'] || def.file.contentType, filename: name, body: a.body ?? Buffer.alloc(0) } };
}
return { data: a.json !== undefined ? a.json : (a.body?.length ? a.body.toString('utf8') : null) } as any;
},
});
}
+34
View File
@@ -50,6 +50,8 @@ jest.mock('../src/utils/campusScope', () => ({
scopeEvent: () => (_req: unknown, res: { status(n: number): { json(b: unknown): void } }, next: () => void) => (mockScope.blockEvent ? res.status(404).json({ message: 'Not found' }) : next()),
scopeBody: () => (_req: unknown, _res: unknown, next: () => void) => next(),
listWhere: async () => ({}),
scopeList: (_req: unknown, _res: unknown, next: () => void) => next(),
unscopedOnly: (_req: unknown, _res: unknown, next: () => void) => next(),
}));
const mockEvents = { listManagedEvents: jest.fn(), createEvent: jest.fn(), updateEvent: jest.fn(), deactivateEvent: jest.fn(), getNotifyRecipients: jest.fn(), setNotifyRecipients: jest.fn(), copySetupFrom: jest.fn() };
jest.mock('../src/services/events', () => Object.fromEntries(Object.keys(mockEvents).map(k => [k, (...a: unknown[]) => (mockEvents as Record<string, jest.Mock>)[k](...a)])));
@@ -86,6 +88,14 @@ const mockAdmin = { getRegistrations: jest.fn(), getUserRegistrations: jest.fn()
jest.mock('../src/services/registrationAdmin', () => Object.fromEntries(Object.keys(mockAdmin).map(k => [k, (...a: unknown[]) => (mockAdmin as Record<string, jest.Mock>)[k](...a)])));
jest.mock('../src/utils/seasonPass', () => ({ notOnSeasonPass: (_req: unknown, _res: unknown, next: () => void) => next() }));
jest.mock('../src/controllers/cashupController', () => {
const echo = (req: { params: object; body: object }, res: { json(b: unknown): void }) => res.json({ params: req.params, body: req.body });
return {
getEventCashup: echo, getCashByRecordedUser: echo, savePersonCash: echo, saveEventCashupDraft: echo, reopenEvent: echo, resolvePending: echo, getCashupAudit: echo,
closeEvent: (_req: unknown, res: { status(n: number): { json(b: unknown): void } }) => res.status(400).json({ message: 'There are pending lines.', code: 'PENDING_LINES' }),
};
});
import { v2Router } from '../src/v2';
import { ServiceError } from '../src/services/errors';
import { routes } from '../src/v2/route';
@@ -657,3 +667,27 @@ describe('managing registrations', () => {
expect(res.body.error).toMatchObject({ code: 'VALIDATION_FAILED', message: 'Cannot reduce items below the amount already paid' });
});
});
describe('money (v1 handlers behind v2 routes)', () => {
test("an event's cash-up reaches v1 with the event as :eventId", async () => {
signedIn = { id: 'v1', role: 'supervisor' };
const res = await request(app).get('/api/v2/events/e1/cashup');
expect(res.status).toBe(200);
expect(res.body.data.params).toMatchObject({ eventId: 'e1' });
});
test('closing is for admins, and a v1 refusal keeps its code as the reason', async () => {
signedIn = { id: 'v1', role: 'supervisor' };
expect((await request(app).post('/api/v2/events/e1/cashup/close').send({})).status).toBe(403);
signedIn = { id: 'a1', role: 'admin' };
const res = await request(app).post('/api/v2/events/e1/cashup/close').send({});
expect(res.status).toBe(400);
expect(res.body.error).toEqual({ code: 'VALIDATION_FAILED', message: 'There are pending lines.', reason: 'PENDING_LINES' });
});
test('a cash-up line is given to v1 as :lineId', async () => {
signedIn = { id: 'a1', role: 'admin' };
const res = await request(app).post('/api/v2/cashup-lines/l9/pending').send({ resolution: 'paid' });
expect(res.body.data).toEqual({ params: { lineId: 'l9' }, body: { resolution: 'paid' } });
});
});
+75
View File
@@ -0,0 +1,75 @@
import express from 'express';
import request from 'supertest';
import { PassThrough } from 'stream';
import { z } from 'zod';
jest.mock('../src/config/db', () => ({}));
jest.mock('../src/middleware/authMiddleware', () => ({ protect: jest.fn(), optionalAuth: jest.fn(), admin: jest.fn(), staff: jest.fn(), supervisor: jest.fn() }));
import { mount } from '../src/v2/route';
import { errorHandler } from '../src/v2/errors';
import { v1Route } from '../src/v2/viaV1';
import type { RequestHandler } from 'express';
function appWith(...defs: ReturnType<typeof v1Route>[]) {
const app = express();
app.use(express.json());
const router = express.Router();
mount(router, defs);
app.use('/api/v2', router);
app.use('/api/v2', errorHandler);
return app;
}
describe('v2 routes answered by v1 handlers', () => {
test("v1's JSON becomes data; v2's :id is given to v1 by its old name", async () => {
const handler: RequestHandler = (req, res) => { res.json({ eventId: req.params.eventId, q: req.query.x }); };
const app = appWith(v1Route({ method: 'get', path: '/things/:id', tag: 'T', summary: 's', access: 'public', v1: handler, map: ctx => ({ params: { eventId: ctx.params.id } }), params: z.object({ id: z.string() }) }));
const res = await request(app).get('/api/v2/things/e1?x=2');
expect(res.status).toBe(200);
expect(res.body).toEqual({ data: { eventId: 'e1', q: '2' } });
});
test("a v1 error is a v2 error, with v1's code as the reason", async () => {
const handler: RequestHandler = (_req, res) => { res.status(409).json({ message: 'Already closed.', code: 'EVENT_CLOSED' }); };
const app = appWith(v1Route({ method: 'post', path: '/close', tag: 'T', summary: 's', access: 'public', v1: handler }));
const res = await request(app).post('/api/v2/close').send({});
expect(res.status).toBe(409);
expect(res.body).toEqual({ error: { code: 'CONFLICT', message: 'Already closed.', reason: 'EVENT_CLOSED' } });
});
test('a thrown error and a next(err) both reach the error handler', async () => {
const thrower: RequestHandler = async () => { throw Object.assign(new Error('Nope.'), { statusCode: 403 }); };
const passer: RequestHandler = (_req, _res, next) => next(Object.assign(new Error('Missing.'), { statusCode: 404 }));
const app = appWith(
v1Route({ method: 'get', path: '/a', tag: 'T', summary: 's', access: 'public', v1: thrower }),
v1Route({ method: 'get', path: '/b', tag: 'T', summary: 's', access: 'public', v1: passer }),
);
expect((await request(app).get('/api/v2/a')).body.error).toMatchObject({ code: 'FORBIDDEN', message: 'Nope.' });
expect((await request(app).get('/api/v2/b')).body.error).toMatchObject({ code: 'NOT_FOUND', message: 'Missing.' });
});
test('middleware chains run in order', async () => {
const mark: RequestHandler = (req, _res, next) => { (req as unknown as { mark: string }).mark = 'yes'; next(); };
const handler: RequestHandler = (req, res) => { res.json({ mark: (req as unknown as { mark: string }).mark }); };
const app = appWith(v1Route({ method: 'get', path: '/m', tag: 'T', summary: 's', access: 'public', v1: [mark, handler] }));
expect((await request(app).get('/api/v2/m')).body).toEqual({ data: { mark: 'yes' } });
});
test('a file a v1 handler pipes is sent as a download', async () => {
const handler: RequestHandler = (_req, res) => {
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="ticket.pdf"');
const s = new PassThrough();
s.pipe(res);
s.write(Buffer.from('%PDF-1.4 '));
s.end(Buffer.from('end'));
};
const app = appWith(v1Route({ method: 'get', path: '/pdf', tag: 'T', summary: 's', access: 'public', file: { contentType: 'application/pdf' }, v1: handler }));
const res = await request(app).get('/api/v2/pdf').buffer(true).parse((r, cb) => { const c: Buffer[] = []; r.on('data', (d: Buffer) => c.push(d)); r.on('end', () => cb(null, Buffer.concat(c))); });
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('application/pdf');
expect(res.headers['content-disposition']).toContain('ticket.pdf');
expect((res.body as Buffer).toString()).toBe('%PDF-1.4 end');
});
});
+19 -2
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-29, after **1.51.10** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-29, after **1.51.11** (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).
@@ -17,6 +17,7 @@ encrypted settings unreadable or break existing installs.
| 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.11 | **Money on v2** — `src/v2/viaV1.ts` (`v1Route`: a v2 route answered by the v1 handler); `resources/money.ts`; `/docs/v2` collapsed with a filter |
| 1.51.10 | **Managing registrations on v2** — `src/services/registrationAdmin.ts` (the rest of `registrationController.js`); frontend `fetchAllRegistrations()`; money split out to 1.51.11 |
| 1.51.9 | **Series, season passes, bundles, coupons, campuses on v2** — `src/services/series.ts`, `deals.ts`, `campuses.ts`; `reason` on v2 errors from service codes; frontend `apiV2Data()` |
| 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 |
@@ -70,7 +71,7 @@ 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.11 – 1.51.16: every endpoint onto v2**, one area per release (roadmap build order, row
1. **1.51.12 – 1.51.16: 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.16 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.
@@ -468,6 +469,22 @@ 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).
## v1 handlers behind v2 routes (1.51.11 –)
- **`v1Route({ …defineRoute options, v1: handler, map? })`** (`src/v2/viaV1.ts`) answers a v2 route
with the v1 handler, in-process: the request is the real one (user, headers, campus scope) with
params/query/body replaced by `map(ctx)` — e.g. `map: ctx => ({ params: { eventId: ctx.params.id } })`
where v1 named it `:eventId`. The same campus middleware goes in `middleware`.
- v1 JSON → `{ data }`; a 4xx/5xx → the v2 error (v1 `code` → `reason`); a redirect →
`{ data: { redirectUrl } }`; with `file`, whatever was sent or piped → a download.
- Body and query schemas are loose (`z.looseObject`) and v1 gets the request as sent, so v2 refuses
nothing v1 accepted. Give a `query` schema for filters the frontend passes — the typed `apiV2`
only allows declared ones.
- Moving these handlers into TS services (as 1.51.1 – 1.51.10 did) is a later clean-up; the v2 API
doesn't change when it happens.
- Frontend conversions are scripted from `SUCCESSORS` and the OpenAPI document (scratch tool, not
in the repo); calls with a computed URL are done by hand.
## Managing registrations on v2 (1.51.10)
- **`registrationAdmin.ts` takes a `Call`** — `{ actor, params, body, query, readOnly, ip, listWhere }`
+3 -3
View File
@@ -42,8 +42,8 @@ pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **even
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** and 1.51.10 **managing registrations on v2** shipped. **Next: 1.51.11 –
1.51.16 (every endpoint onto v2), then 2.0.0.**
**series, passes, deals and campuses on v2** 1.51.10 **managing registrations on v2** and 1.51.11 **money on v2** shipped.
**Next: 1.51.12 – 1.51.16 (every endpoint onto v2), then 2.0.0.**
**Repositories:**
@@ -1012,7 +1012,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.10 ✅, 1.51.11 – 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 moves its area's logic into TS services, its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
| 11b | 1.51.4 ✅ – 1.51.11 ✅, 1.51.12 – 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 |
| 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/`),
+26
View File
@@ -216,12 +216,38 @@ test.describe("API v2", () => {
expect((await cancelled.json()).data.status).toBe("cancelled");
});
test("money: providers, cash-up, my payments, and links that don't exist", async ({ request }) => {
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
const providers = await request.get(`${API}/api/v2/payments/providers`);
expect(providers.status()).toBe(200);
expect(await providers.json()).toHaveProperty("data");
const cash = await request.get(`${API}/api/v2/events/${seed().events.conference}/cashup`, { headers: sup });
expect(cash.status()).toBe(200);
expect((await cash.json()).data).toBeTruthy();
const mine = await request.get(`${API}/api/v2/me/payments?page=1&limit=5`, { headers: user });
expect(mine.status()).toBe(200);
const page = await request.get(`${API}/api/v2/payments?page=1&limit=3`, { headers: sup });
expect((await page.json()).data).toMatchObject({ page: 1, limit: 3 });
for (const path of ["/api/v2/waitlist/claims/not-a-real-token", "/api/v2/consent/not-a-real-token", "/api/v2/feedback-forms/not-a-real-token"]) {
const res = await request.get(`${API}${path}`);
expect(res.status(), path).toBeGreaterThanOrEqual(400);
expect((await res.json()).error.code, path).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
}
});
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
await page.goto(`${API}/docs/v2?token=${seed().users.admin.token}`);
await expect(page.getByRole("heading", { name: /Cross Events API/ })).toBeVisible();
// Sections start collapsed (there are hundreds of endpoints): open Events.
await page.locator('h3.opblock-tag[data-tag="Events"]').click();
await expect(page.getByRole("button", { name: /^GET \/events\/\{id\} One public/ })).toBeVisible();
// The token isn't left in the address bar.
expect(page.url()).not.toContain("token=");
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.10",
"version": "1.51.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.51.10",
"version": "1.51.11",
"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.10",
"version": "1.51.11",
"private": true,
"scripts": {
"dev": "next dev",
+5 -4
View File
@@ -3,9 +3,10 @@ import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { ShieldCheck, CheckCircle2, XCircle } from "lucide-react";
import { apiV2Data } from "@/lib/apiV2";
type ConsentInfo = {
status: "pending" | "given" | "declined";
@@ -39,7 +40,7 @@ export default function ConsentPage() {
useEffect(() => {
if (!token) return;
apiFetch<ConsentInfo>(`/api/consent/${encodeURIComponent(token)}`)
(apiV2Data("get", "/consent/{token}", { params: { token: token } }) as Promise<ConsentInfo>)
.then(res => { setInfo(res); setSignedName(res.parentName || ""); })
.catch(e => setLoadError(e instanceof ApiError ? e.message : "Couldn't load this consent request."));
}, [token]);
@@ -50,8 +51,8 @@ export default function ConsentPage() {
if (decision === "give" && info.collect.photo && photoConsent === null) { setError("Please answer the photo consent question."); return; }
setBusy(true);
try {
await apiFetch(`/api/consent/${encodeURIComponent(token)}`, {
method: "POST",
await apiV2Data("post", "/consent/{token}", {
params: { token: token },
body: {
decision, signedName, isGuardian,
...(decision === "give" && info.collect.medical ? { medical } : {}),
@@ -3,13 +3,14 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, fetchEvent } from "@/lib/api";
import { fetchEvent } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import type { EventCost, EventCostType, EventFinancials, CashupMethod, CashupWriteOffCategory, ReconciledByMethod, Event } from "@/types";
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { createCost, updateCost, deleteCost } from "@/lib/eventSetup";
import { apiV2Data } from "@/lib/apiV2";
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
@@ -42,7 +43,7 @@ export default function EventCashupPage() {
setError(null);
try {
const [financials, event] = await Promise.all([
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
(apiV2Data("get", "/events/{id}/cashup", { params: { id: eventId }, authToken: token }) as Promise<EventFinancials>),
fetchEvent<Event>(eventId, { authToken: token })
]);
setData(financials);
@@ -309,7 +310,7 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
const load = useCallback(() => {
if (!token || !eventId) return;
setLoading(true);
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
(apiV2Data("get", "/events/{id}/cashup/cash-by-user", { params: { id: eventId }, authToken: token }) as Promise<AccountabilityResponse>)
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
.finally(() => setLoading(false));
@@ -458,8 +459,10 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
const save = async () => {
setSaving(true); setErr(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/person-cash/${userId}`, {
method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null }
await apiV2Data("put", "/events/{id}/cashup/person-cash/{userId}", {
params: { id: eventId, userId: userId },
authToken: token,
body: { denominations: lines, notes: notes || null },
});
onSaved();
} catch (e) {
@@ -833,7 +836,7 @@ function PendingSettler({ method, r, token, onChanged }: { method: CashupMethod;
if (!r.lineId || !mode) return;
setSaving(true); setErr(null);
try {
await apiFetch(`/api/cashups/lines/${r.lineId}/pending`, { method: "POST", authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
await apiV2Data("post", "/cashup-lines/{id}/pending", { params: { id: r.lineId }, authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
setMode(null);
onChanged();
} catch (e) {
@@ -972,7 +975,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
const saveDraft = async () => {
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } });
await apiV2Data("put", "/events/{id}/cashup/draft", { params: { id: eventId }, authToken: token, body: { lines: buildLinesPayload() } });
onChanged();
} catch (e) {
setError(errorMessage(e) || "Failed to save draft");
@@ -992,7 +995,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
if (!confirm(`Close this event with this cashup? Unallocated donations will be counted as profit and the event will be locked until it's reopened.${warning}`)) return;
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
await apiV2Data("post", "/events/{id}/cashup/close", { params: { id: eventId }, authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e) {
@@ -1006,7 +1009,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
if (!confirm("Quick close this event without a cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return;
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
await apiV2Data("post", "/events/{id}/cashup/close", { params: { id: eventId }, authToken: token, body: { notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e) {
@@ -1020,7 +1023,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
if (!confirm("Reopen this event? Registrations, payments, refunds and donations will be allowed again.")) return;
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } });
await apiV2Data("post", "/events/{id}/cashup/reopen", { params: { id: eventId }, authToken: token, body: { notes: reopenNotes || null } });
onChanged();
} catch (e) {
setError(errorMessage(e) || "Failed to reopen event");
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchManagedEvents, fetchAllRegistrations } from "@/lib/api";
import { fetchManagedEvents, fetchAllRegistrations } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ClipboardList } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -125,7 +125,7 @@ export default function AdminRegistrationsPage() {
if (!formResponses[id] && !loadingForms.has(id)) {
setLoadingForms(prev => new Set(prev).add(id));
try {
const res = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
const res = await (apiV2Data("get", "/form-responses", { query: { registrationId: id }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
const items = Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
setFormResponses(prev => ({ ...prev, [id]: items }));
} catch {
@@ -704,14 +704,13 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: {
try {
setSaving(true);
await apiFetch("/api/payments", {
method: "POST",
authToken: token,
body: {
await apiV2Data("post", "/payments", {
authToken: token,
body: {
registrationId: registration.id,
amount: parseFloat(amount),
method
}
},
});
const updated = await (apiV2Data("get", "/registrations/{id}/full", {
@@ -1426,16 +1425,15 @@ function DonationModal({ open, onClose, user, token, eventId, onSuccess, setErro
try {
setSaving(true);
await apiFetch("/api/payments", {
method: "POST",
authToken: token,
body: {
await apiV2Data("post", "/payments", {
authToken: token,
body: {
amount: amt,
method,
userId: user?.id || undefined,
eventId,
isDonation: true
}
},
});
onSuccess?.();
@@ -1665,16 +1663,15 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
setSaving(true);
setLocalError("");
try {
await apiFetch("/api/payments/refund", {
method: "POST",
authToken: token,
body: {
await apiV2Data("post", "/payments/refunds", {
authToken: token,
body: {
userId: registration.userId,
registrationId: registration.id,
amount: amt,
method,
reason: reason || undefined,
}
},
});
// Re-fetch registration to get updated status & payments
const updated = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registration.id }, authToken: token }) as Promise<Registration>);
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
import type { Event, ConsentRow, MedicalRow } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
@@ -38,7 +39,8 @@ export default function ConsentTrackerPage() {
const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState<string>(search.get("eventId") || "");
const [data, setData] = useState<{ event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
type ConsentData = { event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] };
const [data, setData] = useState<ConsentData | null>(null);
const [filter, setFilter] = useState<"minors" | "all" | "waiting">("minors");
const [busy, setBusy] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
@@ -60,7 +62,7 @@ export default function ConsentTrackerPage() {
if (!token || !eventId) return;
setBusy(true);
try {
setData(await apiFetch(`/api/consent/events/${encodeURIComponent(eventId)}`, { authToken: token }));
setData(await (apiV2Data("get", "/events/{id}/consent", { params: { id: eventId }, authToken: token }) as Promise<ConsentData>));
} catch (e) {
setError(errorMessage(e) || "Failed to load consent status");
} finally {
@@ -79,10 +81,10 @@ export default function ConsentTrackerPage() {
const outdatedCount = (data?.rows || []).filter(r => r.request?.outdated || (r.minor && r.status === "missing")).length;
const act = async (fn: () => Promise<{ message: string }>) => {
const act = async (fn: () => Promise<unknown>) => {
setBusy(true);
try {
const res = await fn();
const res = (await fn()) as { message: string };
setInfo(res.message);
await load();
} catch (e) {
@@ -92,14 +94,14 @@ export default function ConsentTrackerPage() {
}
};
const resendOne = (r: ConsentRow) => act(() => apiFetch(`/api/consent/requests/${r.request!.id}/resend`, { method: "POST", authToken: token! }));
const resendAll = () => act(() => apiFetch(`/api/consent/events/${eventId}/resend-pending`, { method: "POST", authToken: token! }));
const rerequest = () => act(() => apiFetch(`/api/consent/events/${eventId}/rerequest`, { method: "POST", authToken: token! }));
const resendOne = (r: ConsentRow) => act(() => apiV2Data("post", "/consent-requests/{id}/resend", { params: { id: r.request!.id }, authToken: token, body: {} }));
const resendAll = () => act(() => apiV2Data("post", "/events/{id}/consent/resend-pending", { params: { id: eventId }, authToken: token, body: {} }));
const rerequest = () => act(() => apiV2Data("post", "/events/{id}/consent/rerequest", { params: { id: eventId }, authToken: token, body: {} }));
// Printable list for camp leaders. Opening it is recorded in the audit log.
const printMedical = async () => {
try {
const res = await apiFetch<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
const res = await (apiV2Data("get", "/events/{id}/consent/medical", { params: { id: eventId }, authToken: token }) as Promise<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>);
const w = window.open("", "_blank");
if (!w) { setError("Allow pop-ups to print the medical list."); return; }
const rowsHtml = res.rows.map(r => `
@@ -1077,7 +1077,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
const feedbackFrom = mode === "edit" ? ev?.id : cloning ? source?.id : undefined;
useEffect(() => {
if (!feedbackFrom || !token) return;
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${feedbackFrom}`, { authToken: token }).then(f => {
(apiV2Data("get", "/events/{id}/feedback", { params: { id: feedbackFrom }, authToken: token }) as Promise<FeedbackFormResponse | null>).then(f => {
if (!f) return;
setFbEnabled(true);
if (cloning) {
@@ -1111,8 +1111,9 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
};
const saveFeedback = async (eventId: string) => {
if (fbEnabled) {
await apiFetch(`/api/feedback/event/${eventId}`, {
method: "PUT", authToken: token || undefined,
await apiV2Data("put", "/events/{id}/feedback", {
params: { id: eventId },
authToken: token,
body: {
sendAt: fbSettings.sendAt ? new Date(fbSettings.sendAt).toISOString() : null,
audience: fbSettings.audience, anonymous: fbSettings.anonymous, intro: fbSettings.intro || null,
@@ -1120,7 +1121,7 @@ function EventModal({ mode, event: ev, source, onClose, onSuccess }: EventModalP
},
});
} else if (fbMeta) {
await apiFetch(`/api/feedback/event/${eventId}`, { method: "DELETE", authToken: token || undefined });
await apiV2Data("delete", "/events/{id}/feedback", { params: { id: eventId }, authToken: token });
}
};
@@ -4,7 +4,7 @@ import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/s
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { fetchAllUsers, fetchManagedEvents, fetchEvent } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -221,8 +221,7 @@ export default function FormsBrowserPage() {
if (userId.trim()) qs.set("userId", userId.trim());
if (registrationId.trim()) qs.set("registrationId", registrationId.trim());
if (append && nextCursor) qs.set("cursor", nextCursor);
const url = "/api/forms/responses" + (qs.toString() ? `?${qs.toString()}` : "");
const res = await apiFetch<{ items: FormResponse[]; nextCursor?: string }>(url, { authToken: token });
const res = await (apiV2Data("get", "/form-responses", { query: Object.fromEntries(qs), authToken: token }) as Promise<{ items: FormResponse[]; nextCursor?: string }>);
const newItems = Array.isArray(res?.items) ? res!.items : [];
setItems(prev => append ? [...prev, ...newItems] : newItems);
setNextCursor(res?.nextCursor || null);
@@ -380,7 +379,7 @@ export default function FormsBrowserPage() {
const evFull = await fetchEvent<Event>(reg.event.id);
setFillForm(toFormDef(evFull?.form));
}
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const resList = await (apiV2Data("get", "/form-responses", { query: { registrationId: fillRegistrationId }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
setExistingResponses(responseItems(resList));
} catch (e) {
setError(errorMessage(e) || 'Failed to load registration/form');
@@ -441,7 +440,7 @@ export default function FormsBrowserPage() {
body: { responses: editableResponses },
});
setInfo('Responses saved');
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const resList = await (apiV2Data("get", "/form-responses", { query: { registrationId: fillRegistrationId }, authToken: token }) as Promise<{ items?: FormResponse[] } | FormResponse[]>);
setExistingResponses(responseItems(resList));
} catch (e) {
setError(errorMessage(e) || 'Failed to save responses');
@@ -3,7 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchManagedEvents, fetchEvent, fetchAllRegistrations } from "@/lib/api";
import { fetchAllUsers, fetchManagedEvents, fetchEvent, fetchAllRegistrations } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
@@ -164,8 +164,7 @@ export default function ManualRegistrationPage() {
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
try {
setPaySubmitting(true);
await apiFetch("/api/payments", {
method: "POST",
await apiV2Data("post", "/payments", {
authToken: token,
body: {
amount: amt,
@@ -174,7 +173,7 @@ export default function ManualRegistrationPage() {
registrationId: payRegistrationId,
isDonation: false,
paidAt: payPaidAtLocal ? new Date(payPaidAtLocal).toISOString() : undefined,
}
},
});
setPayMessage(`Payment recorded (R ${amt.toFixed(2)}).`);
setPayAmount("");
@@ -3,7 +3,7 @@
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments, fetchManagedEvents, fetchAllRegistrations } from "@/lib/api";
import { fetchAllUsers, fetchAllPayments, fetchManagedEvents, fetchAllRegistrations } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
@@ -14,6 +14,7 @@ import { BackLink } from "@/components/shared/BackLink";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { WaitingForPayment } from "@/components/payments/WaitingForPayment";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
@@ -193,7 +194,7 @@ function PaymentsContent() {
setLoadingList(true);
const start = new Date(); start.setHours(0, 0, 0, 0);
const [recent, today, donations] = await Promise.all([
apiFetch<{ data: Payment[] }>("/api/payments?page=1&limit=40", { authToken: token }),
(apiV2Data("get", "/payments", { query: { page: "1", limit: "40" }, authToken: token }) as Promise<{ data: Payment[] }>),
fetchAllPayments(token, { from: start.toISOString() }),
fetchAllPayments(token, { donations: "1" }),
]);
@@ -301,17 +302,16 @@ function PaymentsContent() {
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
try {
setLinkGenerating(true);
const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/checkout", {
method: "POST",
const res = await (apiV2Data("post", "/registrations/{id}/payment-link", {
params: { id: linkRegistrationId },
authToken: token,
body: {
registrationId: linkRegistrationId,
amount: amt,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
});
}) as Promise<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>);
if (res.priceUpdated) {
setError(res.message || "Pricing has changed for this registration; please re-check the amount.");
return;
@@ -342,10 +342,10 @@ function PaymentsContent() {
setError(null); setInfo(null);
try {
setLinkSending(channel);
await apiFetch("/api/payments/checkout/send", {
method: "POST",
await apiV2Data("post", "/registrations/{id}/payment-link/send", {
params: { id: linkResult.registrationId },
authToken: token,
body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel },
body: { redirectUrl: linkResult.redirectUrl, channel },
});
setInfo(`Payment link sent via ${channel === "email" ? "email" : "WhatsApp"}`);
} catch (e) {
@@ -405,8 +405,7 @@ function PaymentsContent() {
}
try {
setSubmitting(true);
await apiFetch("/api/payments", {
method: "POST",
await apiV2Data("post", "/payments", {
authToken: token,
body: {
amount: amt,
@@ -416,7 +415,7 @@ function PaymentsContent() {
eventId: eventId || undefined,
isDonation: !!isDonation,
paidAt: paidAtLocal ? new Date(paidAtLocal).toISOString() : undefined,
}
},
});
setInfo(`Payment created (R ${amt.toFixed(2)})`);
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
@@ -447,7 +446,7 @@ function PaymentsContent() {
if (!token) return;
try {
setTxLoading(true);
const res = await apiFetch<{ data?: ProviderTx[] } | ProviderTx[]>("/api/provider-transactions/unreconciled", { authToken: token });
const res = await (apiV2Data("get", "/provider-transactions/unreconciled", { authToken: token }) as Promise<{ data?: ProviderTx[] } | ProviderTx[]>);
const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
setProviderTxs(items);
} catch {
@@ -486,18 +485,18 @@ function PaymentsContent() {
setReconcileForm(prev => ({ ...prev, submitting: true }));
if (type === 'reg') {
if (!registrationId) { setError('Please select a registration'); return; }
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
await apiV2Data("post", "/provider-transactions/{id}/reconcile", {
params: { id: txId },
authToken: token,
body: { registrationId }
body: { registrationId },
});
setInfo('Reconciled to registration');
} else {
if (!eventId) { setError('Please select an event'); return; }
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
await apiV2Data("post", "/provider-transactions/{id}/reconcile", {
params: { id: txId },
authToken: token,
body: { eventId, userId: userId || undefined }
body: { eventId, userId: userId || undefined },
});
setInfo('Reconciled as donation');
}
@@ -617,7 +616,7 @@ function PaymentsContent() {
const ok = window.confirm('Ignore this transaction? It will be marked as ignored and reconciled.');
if (!ok) return;
try {
await apiFetch(`/api/provider-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
await apiV2Data("post", "/provider-transactions/{id}/ignore", { params: { id: tx.id }, authToken: token, body: {} });
setInfo('Transaction ignored');
await loadUnreconciled();
} catch (e) {
@@ -935,9 +934,8 @@ function RefundSection({ allUsers, onDone }: RefundSectionProps) {
if (!method) { setErr('Select a refund method'); return; }
try {
setSubmitting(true);
await apiFetch('/api/payments/refund', {
method: 'POST',
authToken: token!,
await apiV2Data("post", "/payments/refunds", {
authToken: token,
body: {
userId,
amount: amt,
@@ -945,7 +943,7 @@ function RefundSection({ allUsers, onDone }: RefundSectionProps) {
paymentId: target === 'payment' ? paymentId : undefined,
registrationId: target === 'registration' ? registrationId : undefined,
reason: reason || undefined
}
},
});
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
await onDone();
@@ -1083,10 +1081,9 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
if (!ok) return;
try {
setUnassigningId(leg.id);
await apiFetch('/api/payments/unassign-donation', {
method: 'POST',
await apiV2Data("post", "/payments/donations/unassign", {
authToken: token,
body: { legId: leg.id }
body: { legId: leg.id },
});
await onDone();
} catch (e) {
@@ -1266,8 +1263,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
if (amountNum > maxAllocatable + 0.000001) { setErr(`Cannot allocate more than R ${maxAllocatable.toFixed(2)}`); return; }
try {
setAssigning(true);
await apiFetch('/api/payments/assign-donation', {
method: 'PUT',
await apiV2Data("post", "/payments/donations/assign", {
authToken: token,
body: { paymentId, registrationId, amount: amountNum }
});
@@ -3,7 +3,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchManagedEvents } from "@/lib/api";
import { fetchManagedEvents } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
@@ -13,6 +13,7 @@ import { entryLineName } from "@/lib/waitlist";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { EventPicker } from "@/components/shared/EventPicker";
import { apiV2Data } from "@/lib/apiV2";
type Entry = {
id: string;
@@ -72,7 +73,7 @@ export default function WaitlistPage() {
if (!token || !eventId) return;
setBusy(true);
try {
setEntries(await apiFetch<Entry[]>(`/api/waitlist/events/${encodeURIComponent(eventId)}`, { authToken: token }));
setEntries(await (apiV2Data("get", "/events/{id}/waitlist", { params: { id: eventId }, authToken: token }) as Promise<Entry[]>));
} catch (e) {
setError(errorMessage(e) || "Failed to load the waitlist");
} finally {
@@ -81,10 +82,11 @@ export default function WaitlistPage() {
}, [token, eventId, setError]);
useEffect(() => { load(); }, [load]);
const act = async (path: string, body?: { direction: "up" | "down" }) => {
const act = async (id: string, action: "offer" | "move" | "remove", body: { direction?: "up" | "down" } = {}) => {
setBusy(true);
try {
const res = await apiFetch<{ message?: string }>(path, { method: "POST", authToken: token!, body });
const path = action === "offer" ? "/waitlist/{id}/offer" : action === "move" ? "/waitlist/{id}/move" : "/waitlist/{id}/remove";
const res = await (apiV2Data("post", path, { params: { id }, authToken: token, body }) as Promise<{ message?: string }>);
if (res?.message) setInfo(res.message);
await load();
} catch (e) {
@@ -164,15 +166,15 @@ export default function WaitlistPage() {
<TableCell className="text-right whitespace-nowrap">
{e.status === "waiting" && (
<>
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "up" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move up"><ArrowUp className="w-3.5 h-3.5" /></button>
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "down" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move down"><ArrowDown className="w-3.5 h-3.5" /></button>
<button disabled={busy} onClick={() => act(e.id, "move", { direction: "up" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move up"><ArrowUp className="w-3.5 h-3.5" /></button>
<button disabled={busy} onClick={() => act(e.id, "move", { direction: "down" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move down"><ArrowDown className="w-3.5 h-3.5" /></button>
</>
)}
{(e.status === "waiting" || e.status === "expired") && (
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/offer`)} className="px-2 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 mr-1" title="Offer now, even if no place has freed up">Offer place</button>
<button disabled={busy} onClick={() => act(e.id, "offer")} className="px-2 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 mr-1" title="Offer now, even if no place has freed up">Offer place</button>
)}
{active && (
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/remove`)} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Remove</button>
<button disabled={busy} onClick={() => act(e.id, "remove")} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Remove</button>
)}
</TableCell>
)}
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
import { useDismissingState } from "@/hooks/useDismissingState";
import { HandHeart } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -41,8 +42,7 @@ export default function DonatePage() {
setError(null);
setInfo(null);
setLoading(true);
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/checkout", {
method: "POST",
const res = await (apiV2Data("post", "/donations/checkout", {
body: {
eventId,
amount: amt,
@@ -51,7 +51,7 @@ export default function DonatePage() {
failureUrl: window.location.origin + "/payment/failure",
},
authToken: token,
});
}) as Promise<{ redirectUrl: string }>);
setInfo("Redirecting to payment...");
window.location.href = res.redirectUrl;
} catch (e) {
+7 -7
View File
@@ -143,7 +143,7 @@ export default function UserDashboardPage() {
await Promise.all(
myRegs.map(async (r) => {
try {
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: r.id }, authToken: token }) as Promise<Payment[]>);
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
// totalDueComputed is not time-dependent, no need to recompute per payment time
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
@@ -295,11 +295,11 @@ export default function UserDashboardPage() {
loadingSubtitle: "Please wait while we prepare your receipt.",
});
try {
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
const res = await (apiV2Data("post", "/payments/{id}/receipt", {
params: { id: paymentId },
body: { channel },
authToken: token,
});
}) as Promise<{ message?: string }>);
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
} catch (e) {
setDialog({ open: false, message: "", loading: false });
@@ -333,7 +333,7 @@ export default function UserDashboardPage() {
try {
const now = new Date();
const r = await (apiV2Data("get", "/registrations/{id}/full", { params: { id: registrationId }, authToken: token }) as Promise<Registration>);
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: registrationId }, authToken: token }) as Promise<Payment[]>);
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
setBilling(prev => ({ ...prev, [registrationId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
@@ -417,7 +417,7 @@ export default function UserDashboardPage() {
const myRegs = await (apiV2Data("get", "/me/registrations/full", { query: { past: showPast ? "true" : "false" }, authToken: token }) as Promise<Registration[]>);
setRegistrations(myRegs);
const updated = myRegs.find(r => r.id === activeRegId);
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: activeRegId }, authToken: token }) as Promise<Payment[]>);
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = updated?.totalDueComputed ?? 0;
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
@@ -614,7 +614,7 @@ export default function UserDashboardPage() {
setBilling(totals);
// Payments will be refreshed lazily or on next open; fetch for this one immediately
try {
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeReg.id)}`, { authToken: token });
const pays = await (apiV2Data("get", "/registrations/{id}/payments", { params: { id: activeReg.id }, authToken: token }) as Promise<Payment[]>);
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = totals[activeReg.id]?.totalDue ?? 0;
setBilling(prev => ({
@@ -2,7 +2,6 @@
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { formatDateTime } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
@@ -10,6 +9,7 @@ import { Receipt } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import type { Paginated } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2Data } from "@/lib/apiV2";
interface PaymentItem {
id: string;
@@ -52,11 +52,11 @@ export default function UserPaymentsPage() {
setInfo(null);
setSendingReceipt(true);
try {
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
const res = await (apiV2Data("post", "/payments/{id}/receipt", {
params: { id: paymentId },
body: { channel },
authToken: token,
});
}) as Promise<{ message?: string }>);
setInfo((res && res.message) || "Receipt sent.");
} catch (e) {
setError(errorMessage(e) || "Failed to send receipt");
@@ -77,7 +77,7 @@ export default function UserPaymentsPage() {
if (endDate) qs.set("endDate", endDate);
if (method) qs.set("method", method);
if (kind) qs.set("kind", kind);
return `/api/payments/mypayments?${qs.toString()}`;
return Object.fromEntries(qs);
}, [startDate, endDate, method, kind]);
const loadPayments = useCallback(async (p = 1) => {
@@ -85,7 +85,7 @@ export default function UserPaymentsPage() {
setError(null);
setFetching(true);
try {
const res = await apiFetch<Paginated<PaymentItem>>(buildQuery(p), { authToken: token });
const res = await (apiV2Data("get", "/me/payments", { query: buildQuery(p), authToken: token }) as Promise<Paginated<PaymentItem>>);
setPayments(Array.isArray(res?.data) ? res.data : []);
setTotal(res?.total ?? 0);
setTotalPages(res?.pages ?? 1);
+3 -3
View File
@@ -3,11 +3,11 @@ import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { MessageSquareHeart, CheckCircle2 } from "lucide-react";
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type Field = { id: string; type: string; label: string; isRequired: boolean; helpText: string | null; options: unknown };
type FeedbackInfo = {
@@ -34,7 +34,7 @@ export default function FeedbackPage() {
useEffect(() => {
if (!token) return;
apiFetch<FeedbackInfo>(`/api/feedback/public/${encodeURIComponent(token)}`)
(apiV2Data("get", "/feedback-forms/{token}", { params: { token: token } }) as Promise<FeedbackInfo>)
.then(setInfo)
.catch(e => setLoadError(e?.message || "This feedback link isn't valid."));
}, [token]);
@@ -45,7 +45,7 @@ export default function FeedbackPage() {
setBusy(true);
setError(null);
try {
await apiFetch(`/api/feedback/public/${encodeURIComponent(token!)}`, { method: "POST", body: { answers } });
await apiV2Data("post", "/feedback-forms/{token}", { params: { token: token }, body: { answers } });
setDone(true);
} catch (err) {
setError(errorMessage(err) || "Couldn't send your feedback");
+4 -3
View File
@@ -3,8 +3,9 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { CreditCard } from "lucide-react";
import { apiV2Data } from "@/lib/apiV2";
type ProviderForm = { action: string; fields: Record<string, string> };
type PayPage = {
@@ -40,7 +41,7 @@ export default function PayPage() {
useEffect(() => {
if (!id) return;
apiFetch<PayPage>(`/api/payments/pay/${encodeURIComponent(id)}`)
(apiV2Data("get", "/payment-links/{id}", { params: { id: id } }) as Promise<PayPage>)
.then(p => {
setPage(p);
if (p.status === "open") go(p);
@@ -56,7 +57,7 @@ export default function PayPage() {
setBusy(provider);
setError(null);
try {
go(await apiFetch<{ redirectUrl: string | null; form: ProviderForm | null }>(`/api/payments/pay/${encodeURIComponent(id)}`, { method: "POST", body: { provider } }));
go(await (apiV2Data("post", "/payment-links/{id}", { params: { id: id }, body: { provider } }) as Promise<{ redirectUrl: string | null; form: ProviderForm | null }>));
} catch (e) {
setError(e instanceof ApiError ? e.message : "Couldn't start the payment. Please try again.");
setBusy(null);
@@ -2,8 +2,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { apiV2 } from "@/lib/apiV2";
import { apiV2, apiV2Data } from "@/lib/apiV2";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
@@ -394,7 +393,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
useEffect(() => {
const t = new URLSearchParams(window.location.search).get("waitlist");
if (!t) return;
apiFetch<WaitlistOffer & { status: string }>(`/api/waitlist/claim/${encodeURIComponent(t)}`)
(apiV2Data("get", "/waitlist/claims/{token}", { params: { token: t } }) as Promise<WaitlistOffer & { status: string }>)
.then(res => {
if (res.status !== "offered") { setOfferError("This waitlist offer has expired."); return; }
setWaitlistToken(t);
@@ -3,9 +3,10 @@ import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { ListChecks } from "lucide-react";
import { apiV2Data } from "@/lib/apiV2";
type Claim = {
status: "offered" | "expired";
@@ -30,7 +31,7 @@ export default function WaitlistClaimPage() {
useEffect(() => {
if (!token) return;
apiFetch<Claim>(`/api/waitlist/claim/${encodeURIComponent(token)}`)
(apiV2Data("get", "/waitlist/claims/{token}", { params: { token: token } }) as Promise<Claim>)
.then(setClaim)
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this offer."));
}, [token]);
@@ -3,11 +3,11 @@
import React, { useCallback, useEffect, useState } from "react";
import { ArrowDown, ArrowUp, Wallet } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
import { YocoTab } from "@/components/admin/YocoTab";
import { PayFastSettings } from "@/components/admin/PayFastSettings";
import { apiV2Data } from "@/lib/apiV2";
type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean };
@@ -25,7 +25,7 @@ export function PaymentsTab({ active }: { active: boolean }) {
const load = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ providers: ProviderSetting[]; minimumAmount: number }>("/api/payments/providers/settings", { authToken: token });
const res = await (apiV2Data("get", "/payments/providers/settings", { authToken: token }) as Promise<{ providers: ProviderSetting[]; minimumAmount: number }>);
setProviders(res.providers);
setMinimum(res.minimumAmount);
} catch (e) {
@@ -40,9 +40,10 @@ export function PaymentsTab({ active }: { active: boolean }) {
setProviders(next);
setSaving(true);
try {
const res = await apiFetch<{ message: string; providers: ProviderSetting[] }>("/api/payments/providers/settings", {
method: "PUT", authToken: token, body: { providers: next.map(p => ({ id: p.id, enabled: p.enabled })) },
});
const res = await (apiV2Data("put", "/payments/providers/settings", {
authToken: token,
body: { providers: next.map(p => ({ id: p.id, enabled: p.enabled })) },
}) as Promise<{ message: string; providers: ProviderSetting[] }>);
setProviders(res.providers);
setMessage({ type: "ok", text: res.message });
} catch (e) {
@@ -2,10 +2,11 @@
import React, { useState } from "react";
import { ListPlus, X } from "lucide-react";
import { apiFetch, ApiError } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { WaitlistLine } from "@/lib/waitlist";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
import { apiV2Data } from "@/lib/apiV2";
export type { WaitlistLine };
@@ -40,9 +41,8 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
setBusy(true);
setError(null);
try {
const res = await apiFetch<{ message: string }>("/api/waitlist", {
method: "POST",
authToken: token || undefined,
const res = await (apiV2Data("post", "/waitlist", {
authToken: token,
body: {
eventId,
eventOptionId: line.eventOptionId,
@@ -51,7 +51,7 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
quantity: qty,
...(!loggedIn ? { guestName: guest.name.trim(), guestEmail: guest.email.trim(), guestPhone: guest.phone.trim() || undefined } : {}),
},
});
}) as Promise<{ message: string }>);
setDone(res.message);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't join the waitlist. Please try again.");
@@ -2,11 +2,11 @@
import React, { useEffect, useState } from "react";
import { HeartHandshake, X } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { ModalOverlay } from "@/components/shared/ModalOverlay";
import { apiV2Data } from "@/lib/apiV2";
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
@@ -35,7 +35,7 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
const online = useOnlinePayments();
useEffect(() => {
apiFetch<Sponsorship>(`/api/sponsorships/event/${encodeURIComponent(eventId)}`).then(setData).catch(() => setData(null));
(apiV2Data("get", "/events/{id}/sponsorship", { params: { id: eventId } }) as Promise<Sponsorship>).then(setData).catch(() => setData(null));
try {
const s = new URLSearchParams(window.location.search).get("sponsored");
if (s) setReturned(s);
@@ -55,8 +55,7 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
setBusy(true);
setError(null);
try {
const res = await apiFetch<{ redirectUrl: string }>("/api/sponsorships/checkout", {
method: "POST",
const res = await (apiV2Data("post", "/sponsorships/checkout", {
...(loggedIn ? { authToken: token! } : {}),
body: {
eventId,
@@ -66,7 +65,7 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
...(message.trim() ? { message: message.trim() } : {}),
...(!loggedIn ? { name: guest.name, email: guest.email } : {}),
},
});
}) as Promise<{ redirectUrl: string }>);
window.location.href = res.redirectUrl;
} catch (err) {
setError(errorMessage(err) || "Couldn't start the payment");
@@ -1,8 +1,8 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { apiV2Data } from "@/lib/apiV2";
type OpenCheckout = {
id: string;
@@ -40,7 +40,7 @@ export function WaitingForPayment({ token, onRecorded }: { token: string | null;
const load = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ checkouts: OpenCheckout[] }>("/api/payments/checkouts/open", { authToken: token });
const res = await (apiV2Data("get", "/payments/open-checkouts", { authToken: token }) as Promise<{ checkouts: OpenCheckout[] }>);
setList(res.checkouts);
} catch (e) {
setMessage({ ok: false, text: errorMessage(e) || "Couldn't load waiting payments" });
@@ -53,9 +53,10 @@ export function WaitingForPayment({ token, onRecorded }: { token: string | null;
setBusy(ids?.[0] || "all");
setMessage(null);
try {
const res = await apiFetch<{ results: Record<string, CheckResult>; recorded: number; message: string }>("/api/payments/checkouts/check", {
method: "POST", authToken: token, body: ids ? { ids } : {},
});
const res = await (apiV2Data("post", "/payments/open-checkouts/check", {
authToken: token,
body: ids ? { ids } : {},
}) as Promise<{ results: Record<string, CheckResult>; recorded: number; message: string }>);
setResults(r => ({ ...r, ...res.results }));
setMessage({ ok: true, text: res.message });
if (res.recorded > 0) onRecorded();
@@ -318,7 +318,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
const byEv: Record<string, Payment[]> = {};
for (const id of eventIds) {
try {
const list = await apiFetch<Payment[]>(`/api/payments/event/${encodeURIComponent(id)}`, { authToken: token });
const list = await (apiV2Data("get", "/events/{id}/payments", { params: { id: id }, authToken: token }) as Promise<Payment[]>);
byEv[id] = Array.isArray(list) ? list : [];
} catch {
byEv[id] = [];
@@ -360,7 +360,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
const byEv: Record<string, EventFinancials | null> = {};
for (const id of eventIds) {
try {
byEv[id] = await apiFetch<EventFinancials>(`/api/cashups/event/${encodeURIComponent(id)}`, { authToken: token });
byEv[id] = await (apiV2Data("get", "/events/{id}/cashup", { params: { id: id }, authToken: token }) as Promise<EventFinancials>);
} catch {
byEv[id] = null;
}
@@ -383,7 +383,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
const loadFeedback = async (eventIds: string[]) => {
if (!token || !eventIds.length) return [];
try {
return await apiFetch<FeedbackEventSummary[]>(`/api/feedback/summary?eventIds=${encodeURIComponent(eventIds.join(","))}`, { authToken: token });
return await (apiV2Data("get", "/feedback/summary", { query: { eventIds: eventIds.join(",") }, authToken: token }) as Promise<FeedbackEventSummary[]>);
} catch {
return [];
}
@@ -412,7 +412,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
const ev = filteredEvents.find((e) => e.id === id);
if (!ev?.requiresParentalConsent) continue;
try {
const res = await apiFetch<{ rows?: (ConsentRow | MedicalRow)[] }>(`/api/consent/events/${encodeURIComponent(id)}${kind === "medical" ? "/medical" : ""}`, { authToken: token });
const res = await ((kind === "medical" ? apiV2Data("get", "/events/{id}/consent/medical", { params: { id }, authToken: token }) : apiV2Data("get", "/events/{id}/consent", { params: { id }, authToken: token })) as Promise<{ rows?: (ConsentRow | MedicalRow)[] }>);
(res?.rows || []).forEach((r) => out.push({ ...r, eventTitle: ev.title }));
} catch { /* not allowed or not found — skip */ }
}
@@ -426,7 +426,7 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
if (auditTo) query.set("to", auditTo);
if (eventIds.length === 1) query.set("eventId", eventIds[0]);
try {
const rows = await apiFetch<AuditRow[]>(`/api/cashups/audit?${query.toString()}`, { authToken: token });
const rows = await (apiV2Data("get", "/cashups/audit", { query: Object.fromEntries(query), authToken: token }) as Promise<AuditRow[]>);
const idSet = new Set(eventIds);
const filtered = eventIds.length > 1 ? (rows || []).filter(r => idSet.has(r.eventId)) : (rows || []);
return computeAuditDeltas(filtered).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
@@ -2,7 +2,6 @@
import React, { useMemo, useState } from "react";
import { Users, Send } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
import { errorMessage } from "@/lib/utils";
import type { Attendee } from "@/types";
@@ -139,7 +138,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
const resendConsent = async (requestId: string) => {
try {
const res = await apiFetch<{ message: string }>(`/api/consent/requests/${encodeURIComponent(requestId)}/resend`, { method: "POST", authToken: token });
const res = await (apiV2Data("post", "/consent-requests/{id}/resend", { params: { id: requestId }, authToken: token, body: {} }) as Promise<{ message: string }>);
onMessage(res.message);
} catch (e) {
setError(errorMessage(e) || "Couldn't re-send the consent request");
@@ -2,9 +2,9 @@
import React, { useCallback, useEffect, useState } from "react";
import { ListChecks } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { entryLineName } from "@/lib/waitlist";
import { apiV2Data } from "@/lib/apiV2";
type Entry = {
id: string;
@@ -22,7 +22,7 @@ type Entry = {
export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m: string) => void }) {
const [entries, setEntries] = useState<Entry[]>([]);
const load = useCallback(() => {
apiFetch<Entry[]>("/api/waitlist/mine", { authToken: token }).then(setEntries).catch(() => setEntries([]));
(apiV2Data("get", "/me/waitlist", { authToken: token }) as Promise<Entry[]>).then(setEntries).catch(() => setEntries([]));
}, [token]);
useEffect(() => { load(); }, [load]);
@@ -30,7 +30,7 @@ export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m
const leave = async (id: string) => {
try {
const res = await apiFetch<{ message: string }>(`/api/waitlist/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token });
const res = await (apiV2Data("delete", "/waitlist/{id}", { params: { id: id }, authToken: token }) as Promise<{ message: string }>);
onMessage(res.message);
load();
} catch (e) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -248,8 +248,15 @@ export function fetchAllUsers(token: string, params: Record<string, string> = {}
return fetchAllPages<User>("/api/users", token, params);
}
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<Payment[]> {
return fetchAllPages<Payment>("/api/payments", token, params);
/** Every payment staff may see (API v2 GET /payments, all pages at once). */
export async function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<Payment[]> {
const { apiV2Data } = await import("@/lib/apiV2");
const page = (n: number) => apiV2Data("get", "/payments", { query: { ...params, limit: "200", page: String(n) }, authToken: token }) as Promise<Paginated<Payment>>;
const first = await page(1);
const items: Payment[] = Array.isArray(first?.data) ? [...first.data] : [];
const rest = await Promise.all(Array.from({ length: Math.max(0, (first?.pages || 1) - 1) }, (_, i) => page(i + 2)));
for (const res of rest) if (Array.isArray(res?.data)) items.push(...res.data);
return items;
}
// Starts an online payment for everything still owed on one of my registrations (API v2), with
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { apiV2Data } from "@/lib/apiV2";
type ProvidersResponse = { providers: { id: string; label: string }[]; minimumAmount: number };
@@ -21,7 +21,7 @@ export type OnlinePayments = {
// One request per page load, shared by every component that asks.
let pending: Promise<ProvidersResponse | null> | null = null;
function load() {
if (!pending) pending = apiFetch<ProvidersResponse>("/api/payments/providers").catch(() => null);
if (!pending) pending = (apiV2Data("get", "/payments/providers") as Promise<ProvidersResponse>).catch(() => null);
return pending;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.51.10",
"version": "1.51.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.51.10",
"version": "1.51.11",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.51.10",
"version": "1.51.11",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",