Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c252e0cb3 | ||
|
|
735a76cc74 | ||
|
|
e2e881be85 |
@@ -7,6 +7,29 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.51.1] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **API v2 registrations and payments:** `POST /registrations` (signed in, guest for free
|
||||
events, or a waitlist link), `GET /registrations/{id}`, `POST /registrations/{id}/cancel` and
|
||||
`POST /registrations/{id}/checkout` (online payment, with an optional donation; answers
|
||||
`priceUpdated` with the new balance when an early-bird price ran out). Owners only (admins too);
|
||||
someone else's registration is `NOT_FOUND`.
|
||||
- **Services in TypeScript:** making and cancelling registrations moved from
|
||||
`registrationController.js` to `src/services/registrations.ts`, and paying a registration
|
||||
online from `paymentController.js` to `src/services/payments.ts`. v1 and v2 run the same code;
|
||||
`ServiceError` carries the HTTP status. `registrationController.js` is ~560 lines shorter.
|
||||
- **Typed frontend calls:** `npm run api:types` (repo root) writes the OpenAPI document to
|
||||
`frontend/src/lib/api-v2/openapi.json` and generates `schema.d.ts` from it (openapi-typescript);
|
||||
`apiV2(method, path, { params, query, body, authToken })` in `frontend/src/lib/apiV2.ts` checks
|
||||
the path, parameters, body and answer. A backend test fails while the saved document is stale.
|
||||
|
||||
### Changed
|
||||
|
||||
- The register page, "Pay now" (registration success page and My Events) and cancelling on My
|
||||
Events use API v2. `apiFetch` reads v2 errors (`error.message`, with `code`/`fields` in `data`).
|
||||
|
||||
## [1.51.0] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
+3
-1
@@ -890,7 +890,9 @@ can't start, the same jobs run on in-process timers.
|
||||
set.
|
||||
- Unhandled promise rejections are logged and don't crash the server.
|
||||
- Backend status page (`/`) and built-in API docs page (`/docs`); **API v2** (`/api/v2`, 1.51.0)
|
||||
with one error shape, paged lists and an OpenAPI 3.1 reference at `/docs/v2` (admins).
|
||||
with one error shape, paged lists and an OpenAPI 3.1 reference at `/docs/v2` (admins). Public
|
||||
events, my account and registrations, registering, cancelling and paying online (1.51.1); the
|
||||
frontend's v2 calls are typed from the OpenAPI document.
|
||||
- **TypeScript backend** (1.51.0): `.ts` files are checked strictly, `.js` compiles as it is;
|
||||
deploys build `dist/`, which `src/index.js` runs.
|
||||
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
|
||||
|
||||
@@ -12,6 +12,11 @@ Write for church admins, not developers: no environment variables, endpoints or
|
||||
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
|
||||
-->
|
||||
|
||||
## 1.51.1
|
||||
|
||||
- **More groundwork for 2.0.** Registering, paying online and cancelling now run on the new
|
||||
version of the server's connection to the app. They work just as before.
|
||||
|
||||
## 1.51.0
|
||||
|
||||
- **Groundwork for Cross Events 2.0.** Behind the scenes, the server is moving to a newer, safer
|
||||
|
||||
@@ -6,7 +6,8 @@ Full interactive docs (with request/response examples) are available at `GET /do
|
||||
> (admin token), the document at `GET /api/v2/openapi.json` (admin). It is generated from the route
|
||||
> declarations, so it is not repeated here. v2 responses are `{ data }` (lists add
|
||||
> `meta: { page, limit, total, pages }`, `?page=&limit=` up to 100) and errors
|
||||
> `{ error: { code, message, fields? } }`. This file documents v1 (`/api/…`), which stays until 2.0.
|
||||
> `{ error: { code, message, fields? } }`. This file documents v1 (`/api/…`), which stays until 2.0. The frontend calls v2 through the typed
|
||||
> `apiV2()` helper (`frontend/src/lib/apiV2.ts`, types regenerated with `npm run api:types`).
|
||||
|
||||
All authenticated requests require:
|
||||
```
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -16,7 +16,8 @@
|
||||
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run",
|
||||
"set-demo": "node scripts/set-demo.js",
|
||||
"build": "tsc -p tsconfig.json && node -e \"require('fs').writeFileSync('dist/build.json', JSON.stringify({ version: require('./package.json').version, builtAt: new Date().toISOString() }))\"",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"openapi": "tsx scripts/openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Writes the /api/v2 OpenAPI document to frontend/src/lib/api-v2/openapi.json, from which the
|
||||
* frontend's types are generated (`npm run api:types` in the repo root does both). Run it after
|
||||
* adding or changing a v2 route; tests/v2.test.ts fails while the saved copy is out of date.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { buildOpenApi } from '../src/v2/openapi';
|
||||
import '../src/v2';
|
||||
|
||||
export const OPENAPI_FILE = path.join(__dirname, '..', '..', 'frontend', 'src', 'lib', 'api-v2', 'openapi.json');
|
||||
|
||||
// The version is left out so a release bump doesn't change the file.
|
||||
export const currentDocument = () => JSON.stringify(buildOpenApi({ version: 'v2' }), null, 2) + '\n';
|
||||
|
||||
if (require.main === module) {
|
||||
fs.mkdirSync(path.dirname(OPENAPI_FILE), { recursive: true });
|
||||
fs.writeFileSync(OPENAPI_FILE, currentDocument());
|
||||
console.log(`Wrote ${path.relative(process.cwd(), OPENAPI_FILE)}`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ const { computeDonationRemaining } = require('../utils/donationUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const payments = require('../payments');
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
@@ -941,54 +940,14 @@ const createCheckout = async (req, res) => {
|
||||
throw new Error('Either registrationId or eventId is required');
|
||||
}
|
||||
|
||||
// A registration's balance
|
||||
// A registration's balance (shared with POST /api/v2/registrations/:id/checkout)
|
||||
if (registrationId) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
|
||||
if (!reg) { res.status(404); throw new Error('Registration not found'); }
|
||||
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
|
||||
res.status(403); throw new Error('Not authorized to create checkout for this registration');
|
||||
}
|
||||
// If event is in the past, block self-service payment (mirrors the registration-edit block)
|
||||
if (req.user.role !== 'admin' && req.user.role !== 'supervisor' && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
|
||||
res.status(400); throw new Error('This event has already ended; payment can no longer be made for this registration');
|
||||
}
|
||||
await assertEventOpen(reg.eventId, res);
|
||||
|
||||
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
|
||||
// Must run before the balance is worked out so the checkout charges the right price.
|
||||
const { changed: priceChanged } = await refreshPricingForRegistration(registrationId);
|
||||
if (priceChanged) {
|
||||
const freshReg = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
const newTotal = computeRegistrationTotalDue(freshReg, new Date());
|
||||
const totalPaid = (freshReg.payments || []).reduce((s, p) => s + p.amount, 0);
|
||||
return res.status(200).json({
|
||||
priceUpdated: true,
|
||||
newTotal: Math.max(0, newTotal - totalPaid),
|
||||
message: 'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.'
|
||||
});
|
||||
}
|
||||
|
||||
// Part of the balance (supervisor payments page)
|
||||
if (amount && !isNaN(parseFloat(amount))) {
|
||||
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, amount: parseFloat(amount), provider });
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
// Optional donation added at registration ("Donation amount" on the register form)
|
||||
let donation = 0;
|
||||
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
|
||||
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
|
||||
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
|
||||
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) { res.status(400); throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`); }
|
||||
}
|
||||
|
||||
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation, provider });
|
||||
const { startRegistrationCheckout } = require('../services/payments');
|
||||
const result = await startRegistrationCheckout(registrationId, req.user, {
|
||||
amount: amount && !isNaN(parseFloat(amount)) ? parseFloat(amount) : null,
|
||||
donationAmount: donationAmount === undefined || donationAmount === null || donationAmount === '' ? null : parseFloat(donationAmount),
|
||||
successUrl, cancelUrl, failureUrl, provider,
|
||||
});
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,534 +3,33 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { hashPassword } = require('../config/auth');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
|
||||
const { lineKey, validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
|
||||
const { previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
|
||||
const { validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
|
||||
const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { ensureConsentRequests } = require('../utils/consent');
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const { findOfferByToken, queueWaitlist } = require('../utils/waitlist');
|
||||
const { queueWaitlist } = require('../utils/waitlist');
|
||||
const { reservedOptionIds } = require('../utils/volunteers');
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
function queueConsentRequests(registrationId) {
|
||||
ensureConsentRequests(registrationId).catch(e => console.error('[consent] Failed to send requests:', e?.message || e));
|
||||
}
|
||||
|
||||
// Named attendees for API responses, with whether their ticket has been issued/used.
|
||||
const ATTENDEES_INCLUDE = {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ticket: { select: { id: true, isUsed: true } },
|
||||
// Latest consent request (youth events) — it decides check-in.
|
||||
consentRequests: { orderBy: { createdAt: 'desc' }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
|
||||
},
|
||||
};
|
||||
|
||||
const TICKET_DELIVERY = ['payer', 'each'];
|
||||
|
||||
/**
|
||||
* Places each line will have once `requested` is added to `existingLines` — used to
|
||||
* check attendee names against what's actually being bought.
|
||||
*/
|
||||
function placesAfter(existingLines, requested) {
|
||||
const out = {};
|
||||
// Picked-session lines take no attendee names.
|
||||
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
|
||||
const k = lineKey(l.eventOptionId, l.variantId);
|
||||
out[k] = (out[k] || 0) + Number(l.quantity || 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Active coupon on a registration, for API responses (the discount itself is
|
||||
// Registration.discountTotal).
|
||||
const ACTIVE_COUPONS_INCLUDE = {
|
||||
where: { releasedAt: null },
|
||||
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
|
||||
};
|
||||
// Shared with POST /api/v2/registrations: the registration service (TypeScript, 1.51.1).
|
||||
const registrationService = require('../services/registrations');
|
||||
const {
|
||||
queueConsentRequests, ATTENDEES_INCLUDE, TICKET_DELIVERY, placesAfter, ACTIVE_COUPONS_INCLUDE,
|
||||
pendingCouponLines, priceRequestedOption, checkOptionStock, checkVariantStock, isStaffRole,
|
||||
} = registrationService;
|
||||
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
|
||||
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption, mergeSessionIds } = require('../utils/sessions');
|
||||
|
||||
/**
|
||||
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
||||
* before anything is created. Mirrors buildCouponLines for one tranche per option.
|
||||
*/
|
||||
function pendingCouponLines(event, resolvedOptions) {
|
||||
return buildCouponLines({
|
||||
registrationOptions: resolvedOptions.map(o => ({
|
||||
eventOptionId: o.eventOptionId,
|
||||
variantId: o.variantId || null,
|
||||
quantity: o.quantity,
|
||||
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
|
||||
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
|
||||
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
|
||||
*/
|
||||
async function priceRequestedOption(eventOption, variantId, qty, buyer) {
|
||||
try {
|
||||
const resolved = variantId
|
||||
? await resolveVariantTierPrice(eventOption, variantId, qty, buyer)
|
||||
: await resolveOptionPrice(eventOption, qty, buyer);
|
||||
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
|
||||
} catch (e) {
|
||||
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
|
||||
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
|
||||
return { priceSnapshot: Number(price || 0), appliedTierId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check overall stock availability for an EventOption. Places held for live waitlist
|
||||
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
||||
* Returns { available: boolean, remaining: number|null }
|
||||
*/
|
||||
async function checkOptionStock(option, requestedQty, excludeEntryId = null, sessionId = null) {
|
||||
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check stock availability for a specific variant (same rules as checkOptionStock).
|
||||
*/
|
||||
async function checkVariantStock(variant, requestedQty, excludeEntryId = null, sessionId = null) {
|
||||
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
const { expandPickLines, assertSessionCapacity, isPickOption, mergeSessionIds } = require('../utils/sessions');
|
||||
|
||||
// @desc Create a new registration
|
||||
// @route POST /api/registrations
|
||||
// @access Private
|
||||
const createRegistration = async (req, res) => {
|
||||
try {
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = req.body;
|
||||
|
||||
// Check if event exists
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const optionInclude = canIncludeTiers
|
||||
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: true } : {}) }
|
||||
: true;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: true }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
if (!event.isActive) {
|
||||
res.status(400);
|
||||
throw new Error('Event is not active');
|
||||
}
|
||||
|
||||
if (event.cashupStatus === 'closed') {
|
||||
res.status(400);
|
||||
throw new Error('This event is closed and no longer accepting registrations.');
|
||||
}
|
||||
|
||||
// A waitlist claim link: its token identifies the person and releases the places
|
||||
// held for them. It works without logging in, the same way a consent link does.
|
||||
let waitlistOffer = null;
|
||||
if (waitlistToken) {
|
||||
waitlistOffer = await findOfferByToken(waitlistToken);
|
||||
if (!waitlistOffer || waitlistOffer.eventId !== eventId) { res.status(400); throw new Error('This waitlist link is not valid.'); }
|
||||
if (waitlistOffer.status !== 'offered') { res.status(400); throw new Error(`This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`); }
|
||||
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) { res.status(400); throw new Error('This waitlist offer has expired.'); }
|
||||
if (req.user && req.user.id !== waitlistOffer.userId) { res.status(403); throw new Error('This waitlist offer belongs to someone else.'); }
|
||||
}
|
||||
|
||||
// Enforce auth unless event explicitly opts out
|
||||
const eventRequiresAuth = event.requiresAuth !== false;
|
||||
if (eventRequiresAuth && !req.user && !waitlistOffer) {
|
||||
res.status(401);
|
||||
throw new Error('Authentication required for this event');
|
||||
}
|
||||
|
||||
// Unauthenticated guest registration is only permitted for free events
|
||||
if (!req.user && !waitlistOffer) {
|
||||
const totalDueCheck = (event.eventOptions || []).reduce((sum, eo) => sum + (eo.price || 0), 0);
|
||||
// Also check against the requested options specifically
|
||||
const requestedTotal = (options || []).reduce((sum, opt) => {
|
||||
const eo = event.eventOptions.find(o => o.id === opt.eventOptionId);
|
||||
return sum + ((eo?.price || 0) * (opt.quantity || 1));
|
||||
}, 0);
|
||||
if (requestedTotal > 0) {
|
||||
res.status(400);
|
||||
throw new Error('Guest registration is only available for free events. Please create an account to pay for tickets.');
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve userId — authenticated or guest
|
||||
let userId;
|
||||
if (req.user) {
|
||||
userId = req.user.id;
|
||||
} else if (waitlistOffer) {
|
||||
userId = waitlistOffer.userId;
|
||||
} else {
|
||||
// Guest registration: find existing user by email or create inactive placeholder
|
||||
if (!guestEmail || !guestName) {
|
||||
res.status(400);
|
||||
throw new Error('Name and email are required for unauthenticated registration');
|
||||
}
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) {
|
||||
res.status(409);
|
||||
throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.');
|
||||
}
|
||||
const existingGuest = match.user;
|
||||
if (existingGuest) {
|
||||
userId = existingGuest.id;
|
||||
} else {
|
||||
const newGuest = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name: guestName,
|
||||
email: guestEmail,
|
||||
password: '',
|
||||
phoneNumber: guestPhone || null,
|
||||
isActive: false,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
});
|
||||
userId = newGuest.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce registration cutoff for public registrations (multi-day events close when
|
||||
// the first session starts, or later with late joining — see utils/sessions.js).
|
||||
const now = new Date();
|
||||
if (now >= registrationClosesAt(event, event.sessions)) {
|
||||
res.status(400);
|
||||
throw new Error('Registration is closed for this event');
|
||||
}
|
||||
|
||||
// Validate options
|
||||
if (!options || !Array.isArray(options) || options.length === 0) {
|
||||
res.status(400);
|
||||
throw new Error('At least one option must be selected');
|
||||
}
|
||||
|
||||
// "Pick sessions" options become one line per chosen session; whole-course tickets
|
||||
// on such an event still close when the whole course does.
|
||||
const lines = expandPickLines(event, options, { now });
|
||||
// (An all-sessions ticket with sessions ticked is checked per session instead.)
|
||||
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && now >= wholeCourseClosesAt(event, event.sessions)) {
|
||||
res.status(400);
|
||||
throw new Error('Whole-course tickets are no longer on sale — choose the sessions you want instead.');
|
||||
}
|
||||
await assertSessionCapacity(event, lines, undefined, { excludeEntryId: waitlistOffer?.id });
|
||||
|
||||
// Validate each option and resolve prices / check stock.
|
||||
// Options are independent of each other, so resolve them concurrently. Errors are marked
|
||||
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
|
||||
// across concurrent iterations) — the outer catch always responds 400 regardless.
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
const reserved = new Set(isStaffRole(req.user?.role) ? [] : reservedOptionIds(event));
|
||||
const resolvedOptions = await Promise.all(lines.map(async (option) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption || reserved.has(eventOption.id)) {
|
||||
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
|
||||
}
|
||||
if (!option.quantity || option.quantity < 1) {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
|
||||
const qty = option.quantity;
|
||||
|
||||
// Check overall option stock
|
||||
try {
|
||||
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id, option.sessionId);
|
||||
if (!stockCheck.available) {
|
||||
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.isStockError) throw e; // propagate stock errors
|
||||
// If stock check function fails (pre-migration), continue without stock check
|
||||
}
|
||||
|
||||
// Check variant stock (if a variant is selected)
|
||||
let variantId = option.variantId || null;
|
||||
let variantPrice = null;
|
||||
if (variantId && canIncludeVariants) {
|
||||
const variant = (eventOption.variants || []).find(v => v.id === variantId);
|
||||
if (!variant) {
|
||||
throw new Error(`Variant not found for option "${eventOption.name}"`);
|
||||
}
|
||||
try {
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId);
|
||||
if (!vStock.available) {
|
||||
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.isStockError) throw e;
|
||||
}
|
||||
variantPrice = variant.price; // null = use option price
|
||||
}
|
||||
|
||||
// Resolve price: variant-level tiers take priority, then option-level tiers, then
|
||||
// base prices, then the staff price for Staff and above.
|
||||
let priceSnapshot = null;
|
||||
let appliedTierId = null;
|
||||
if (canIncludeTiers) {
|
||||
const buyer = req.user || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
|
||||
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, buyer));
|
||||
} else {
|
||||
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
|
||||
}
|
||||
if (priceSnapshot === null) priceSnapshot = eventOption.price;
|
||||
|
||||
return { ...option, variantId, appliedTierId, priceSnapshot };
|
||||
}));
|
||||
|
||||
// Check a coupon code before anything is saved, so a bad code doesn't leave a
|
||||
// half-made registration behind. It's applied (and re-checked under a lock) below.
|
||||
if (couponCode) {
|
||||
const buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
await previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
|
||||
}
|
||||
|
||||
// Check for existing non-cancelled registration for this user+event → merge instead
|
||||
const existingReg = await prisma.registration.findFirst({
|
||||
where: { userId, eventId, status: { not: 'cancelled' } },
|
||||
include: { registrationOptions: true }
|
||||
});
|
||||
|
||||
// Attendee names are checked against the places the registration will have after
|
||||
// this purchase, counting names already given on an existing registration.
|
||||
const cleanAttendees = validateAttendees(
|
||||
event,
|
||||
placesAfter(existingReg?.registrationOptions, resolvedOptions),
|
||||
attendees,
|
||||
existingReg ? await namedPlacesByKey(existingReg.id) : {}
|
||||
);
|
||||
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
|
||||
res.status(400);
|
||||
throw new Error("ticketDelivery must be 'payer' or 'each'");
|
||||
}
|
||||
|
||||
let registration;
|
||||
let isNewRegistration = false;
|
||||
if (existingReg) {
|
||||
// Merge: add a new price tranche per requested option into the existing registration.
|
||||
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
|
||||
// tickets bought at different early-bird prices into a single (wrong) price. Each
|
||||
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
|
||||
for (const opt of resolvedOptions) {
|
||||
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
|
||||
);
|
||||
if (existing) {
|
||||
await prisma.$transaction([
|
||||
prisma.registrationOptionTranche.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
registrationOptionId: existing.id,
|
||||
quantity: opt.quantity,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
}
|
||||
}),
|
||||
prisma.registrationOption.update({
|
||||
where: { id: existing.id },
|
||||
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: mergeSessionIds(existing.sessionIds, opt.sessionIds) }) }
|
||||
})
|
||||
]);
|
||||
} else {
|
||||
await prisma.registrationOption.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
registrationId: existingReg.id,
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
sessionIds: opt.sessionIds || [],
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: {
|
||||
create: [{
|
||||
id: uuidv4(),
|
||||
quantity: opt.quantity,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
}]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Only downgrade from paid if the newly added items actually cost something
|
||||
const newItemsCost = resolvedOptions.reduce((sum, opt) => {
|
||||
return sum + ((opt.priceSnapshot || 0) * (opt.quantity || 0));
|
||||
}, 0);
|
||||
if (existingReg.status === 'paid' && newItemsCost > 0) {
|
||||
await prisma.registration.update({ where: { id: existingReg.id }, data: { status: 'partial_paid', updatedAt: new Date() } });
|
||||
} else {
|
||||
await prisma.registration.update({ where: { id: existingReg.id }, data: { updatedAt: new Date() } });
|
||||
}
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: existingReg.id },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
isNewRegistration = true;
|
||||
// Create new registration
|
||||
const registrationId = uuidv4();
|
||||
registration = await prisma.registration.create({
|
||||
data: {
|
||||
id: registrationId,
|
||||
userId,
|
||||
eventId,
|
||||
updatedAt: new Date(),
|
||||
registrationOptions: {
|
||||
create: resolvedOptions.map(option => ({
|
||||
id: uuidv4(),
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
sessionIds: option.sessionIds || [],
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: {
|
||||
create: [{
|
||||
id: uuidv4(),
|
||||
quantity: option.quantity,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
}]
|
||||
}
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cleanAttendees.length > 0) {
|
||||
await createAttendees(registration.id, cleanAttendees);
|
||||
queueConsentRequests(registration.id);
|
||||
}
|
||||
if (waitlistOffer) {
|
||||
await prisma.waitlistEntry.update({
|
||||
where: { id: waitlistOffer.id },
|
||||
data: { status: 'claimed', claimedAt: new Date(), registrationId: registration.id, offerTokenHash: null },
|
||||
});
|
||||
}
|
||||
if (ticketDelivery) {
|
||||
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
||||
}
|
||||
|
||||
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
|
||||
let couponError = null;
|
||||
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
|
||||
if (couponCode) {
|
||||
try {
|
||||
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
|
||||
} catch (e) {
|
||||
// Lost a race for the last use since the check above — keep the registration.
|
||||
if (!e.isCouponError) throw e;
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
if (couponCode || hasDiscount || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registration.id },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If total due is zero, mark as paid and generate/update tickets if no required form
|
||||
let freeTicketMockReq = null, freeTicketMockRes = null;
|
||||
try {
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
if (totalDue === 0) {
|
||||
// Mark paid (no-op if already paid)
|
||||
if (registration.status !== 'paid') {
|
||||
await prisma.registration.update({ where: { id: registration.id }, data: { status: 'paid', updatedAt: new Date() } });
|
||||
}
|
||||
// check if event has a required form
|
||||
let hasRequiredForm = false;
|
||||
try {
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
|
||||
hasRequiredForm = !!(form && form.isRequired);
|
||||
} catch {}
|
||||
if (!hasRequiredForm) {
|
||||
// Same generator as paid registrations: a ticket per named attendee, and one
|
||||
// shared quantity ticket for the rest of each line.
|
||||
await generateTicketsForRegistration(registration.id);
|
||||
// Capture for chained email — tickets sent after registration confirmation
|
||||
freeTicketMockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
|
||||
freeTicketMockRes = { status: () => freeTicketMockRes, json: () => {} };
|
||||
}
|
||||
|
||||
// reflect status change in the returned object
|
||||
registration.status = 'paid';
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore errors in auto-generation path; manual generation remains available
|
||||
}
|
||||
|
||||
// Fire-and-forget: registration email first, then tickets (guarantees order)
|
||||
const { sendRegistrationEmails, sendRegistrationUpdatedEmails } = require('../utils/notifications');
|
||||
const _regId = registration.id;
|
||||
const _freeTicketReq = freeTicketMockReq;
|
||||
const _freeTicketRes = freeTicketMockRes;
|
||||
(async () => {
|
||||
try {
|
||||
if (isNewRegistration) {
|
||||
await sendRegistrationEmails(_regId);
|
||||
} else {
|
||||
await sendRegistrationUpdatedEmails(_regId);
|
||||
}
|
||||
} catch (e) { console.error('Failed to send registration emails:', e); }
|
||||
if (_freeTicketReq) {
|
||||
try { await emailTickets(_freeTicketReq, _freeTicketRes); } catch (e) { console.error('Error emailing tickets for free registration:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Funnel: this registration came from a tracked (cookie-consented) visit.
|
||||
if (analyticsSessionId) {
|
||||
const { recordSubmit } = require('../utils/analytics');
|
||||
recordSubmit(registration, analyticsSessionId).catch(e => console.warn('[analytics] submit not recorded:', e?.message || e));
|
||||
}
|
||||
|
||||
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
|
||||
const actor = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
const { registration, couponError } = await registrationService.createRegistration(req.body || {}, actor);
|
||||
res.status(201).json({ ...registration, ...(couponError ? { couponError } : {}) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -827,71 +326,7 @@ const updateRegistrationStatus = async (req, res) => {
|
||||
// @access Private
|
||||
const cancelRegistration = async (req, res) => {
|
||||
try {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { event: { select: { endDate: true } } }
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
res.status(404);
|
||||
throw new Error('Registration not found');
|
||||
}
|
||||
|
||||
// Check if user is authorized to cancel this registration
|
||||
if (registration.userId !== req.user.id && req.user.role !== 'admin') {
|
||||
res.status(403);
|
||||
throw new Error('Not authorized to cancel this registration');
|
||||
}
|
||||
|
||||
// If event is in the past, block self-cancellation (mirrors the edit-block above)
|
||||
if (req.user.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
|
||||
res.status(400);
|
||||
throw new Error('This event has already ended; registration can no longer be cancelled');
|
||||
}
|
||||
|
||||
await assertEventOpen(registration.eventId, res);
|
||||
|
||||
// Check if registration can be cancelled
|
||||
if (registration.status === 'cancelled') {
|
||||
res.status(400);
|
||||
throw new Error('Registration is already cancelled');
|
||||
}
|
||||
|
||||
// Non-admins cannot cancel a registration that has payments against it
|
||||
if (req.user.role !== 'admin') {
|
||||
const paymentCount = await prisma.payment.count({
|
||||
where: { registrationId: registration.id, amount: { gt: 0 } }
|
||||
});
|
||||
if (paymentCount > 0) {
|
||||
res.status(403);
|
||||
throw new Error('This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.');
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRegistration = await prisma.registration.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
status: 'cancelled',
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
await releaseRegistrationCoupons(req.params.id);
|
||||
queueWaitlist(registration.eventId);
|
||||
|
||||
// Only log when staff cancels on someone else's behalf — a routine self-service
|
||||
// cancellation isn't an admin action worth cluttering the audit trail with.
|
||||
if (registration.userId !== req.user.id) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'registration_cancelled',
|
||||
targetType: 'Registration',
|
||||
targetId: req.params.id,
|
||||
metadata: { registrationOwnerId: registration.userId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
const updatedRegistration = await registrationService.cancelRegistration(req.params.id, req.user, getClientIp(req));
|
||||
res.json({ message: 'Registration cancelled', registration: updatedRegistration });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
@@ -2153,7 +1588,6 @@ async function syncStatusAfterDiscount(registrationId) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const isStaffRole = (role) => ['admin', 'supervisor', 'staff'].includes(role);
|
||||
|
||||
// @desc Apply a coupon code to an existing registration
|
||||
// @route POST /api/registrations/:id/coupon
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* An error from a service (src/services/*) with the HTTP status it means. v2 turns it into its
|
||||
* error shape by the status (v2/errors.ts); v1 controllers keep answering as they always did.
|
||||
*/
|
||||
export class ServiceError extends Error {
|
||||
readonly statusCode: number;
|
||||
/** Extra, e.g. couponError or a code the caller can switch on. */
|
||||
readonly code?: string;
|
||||
|
||||
constructor(statusCode: number, message: string, code?: string) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export const fail = (statusCode: number, message: string, code?: string): never => {
|
||||
throw new ServiceError(statusCode, message, code);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Paying a registration online (1.51.1). Moved from paymentController.createCheckout so
|
||||
* POST /api/payments/checkout (v1) and POST /api/v2/registrations/:id/checkout share it.
|
||||
*/
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const payments = require('../payments') as { MIN_ONLINE_AMOUNT: number };
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts') as {
|
||||
createRegistrationCheckout(registrationId: string, userId: string, opts: { successUrl?: string; cancelUrl?: string; failureUrl?: string; donationAmount?: number; amount?: number | null; provider?: string | null }): Promise<{
|
||||
checkoutId: string; redirectUrl: string; provider: string; amount: number; registration: { id: string; eventId: string; eventTitle: string };
|
||||
}>;
|
||||
};
|
||||
const pricing = require('../utils/pricing') as {
|
||||
refreshPricingForRegistration(id: string): Promise<{ changed: boolean }>;
|
||||
computeRegistrationTotalDue(registration: any, at: Date): number;
|
||||
};
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
|
||||
|
||||
export type CheckoutOptions = {
|
||||
/** Pay only this much of the balance (staff taking part payments). */
|
||||
amount?: number | null;
|
||||
/** An optional donation added on top (the register form's "Donation amount"). */
|
||||
donationAmount?: number | null;
|
||||
provider?: string | null;
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
failureUrl?: string;
|
||||
};
|
||||
|
||||
export type CheckoutResult =
|
||||
| { priceUpdated: true; newTotal: number; message: string }
|
||||
| { priceUpdated?: false; checkoutId: string; redirectUrl: string; provider: string; amount: number; registration: { id: string; eventId: string; eventTitle: string } };
|
||||
|
||||
/**
|
||||
* Starts an online payment for what's owed on a registration (the owner, or a supervisor or
|
||||
* admin). If an early-bird price ran out since it was made, nothing is charged: the caller is
|
||||
* told the new total to show first.
|
||||
*/
|
||||
export async function startRegistrationCheckout(registrationId: string, actor: { id: string; role: Role }, opts: CheckoutOptions = {}): Promise<CheckoutResult> {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
|
||||
if (!reg) return fail(404, 'Registration not found');
|
||||
const staffSide = actor.role === 'admin' || actor.role === 'supervisor';
|
||||
if (reg.userId !== actor.id && !staffSide) fail(403, 'Not authorized to create checkout for this registration');
|
||||
// If event is in the past, block self-service payment (mirrors the registration-edit block)
|
||||
if (!staffSide && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
|
||||
fail(400, 'This event has already ended; payment can no longer be made for this registration');
|
||||
}
|
||||
try {
|
||||
await assertEventOpen(reg.eventId);
|
||||
} catch (e: any) {
|
||||
throw new ServiceError(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
|
||||
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
|
||||
// Must run before the balance is worked out so the checkout charges the right price.
|
||||
const { changed } = await pricing.refreshPricingForRegistration(registrationId);
|
||||
if (changed) {
|
||||
const fresh = await prisma.registration.findUniqueOrThrow({
|
||||
where: { id: registrationId },
|
||||
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
|
||||
});
|
||||
const newTotal = pricing.computeRegistrationTotalDue(fresh, new Date());
|
||||
const totalPaid = fresh.payments.reduce((s, p) => s + p.amount, 0);
|
||||
return {
|
||||
priceUpdated: true,
|
||||
newTotal: Math.max(0, newTotal - totalPaid),
|
||||
message: 'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.',
|
||||
};
|
||||
}
|
||||
|
||||
const urls = { successUrl: opts.successUrl, cancelUrl: opts.cancelUrl, failureUrl: opts.failureUrl, provider: opts.provider ?? null };
|
||||
|
||||
// Part of the balance (supervisor payments page)
|
||||
if (opts.amount !== undefined && opts.amount !== null && !isNaN(Number(opts.amount))) {
|
||||
return createRegistrationCheckout(registrationId, actor.id, { ...urls, amount: Number(opts.amount) });
|
||||
}
|
||||
|
||||
let donation = 0;
|
||||
if (opts.donationAmount !== undefined && opts.donationAmount !== null) {
|
||||
donation = Math.round(Number(opts.donationAmount) * 100) / 100;
|
||||
if (!(donation >= 0) || donation > 100000) fail(400, 'Invalid donation amount');
|
||||
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) fail(400, `Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`);
|
||||
}
|
||||
return createRegistrationCheckout(registrationId, actor.id, { ...urls, donationAmount: donation });
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Making and cancelling registrations (1.51.1). Moved here from registrationController.js so
|
||||
* POST /api/registrations (v1) and POST /api/v2/registrations run the same code. Errors are
|
||||
* ServiceErrors carrying their HTTP status.
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils') as { generateTicketsForRegistration(id: string): Promise<unknown> };
|
||||
const pricing = require('../utils/pricing') as {
|
||||
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
computeRegistrationTotalDue(registration: any, at: Date): number;
|
||||
attachComputedTotals<T>(registration: T): T & { totalDueComputed: number };
|
||||
};
|
||||
const coupons = require('../utils/coupons') as {
|
||||
buildCouponLines(registration: any): any[];
|
||||
previewCoupon(args: { code: string; eventId: string; user: any; lines: any[] }): Promise<unknown>;
|
||||
applyCouponToRegistration(args: { registrationId: string; code: string }): Promise<unknown>;
|
||||
recalculateRegistrationDiscount(registrationId: string): Promise<number>;
|
||||
releaseRegistrationCoupons(registrationId: string): Promise<unknown>;
|
||||
};
|
||||
const attendeesUtil = require('../utils/attendees') as {
|
||||
lineKey(eventOptionId: string, variantId?: string | null): string;
|
||||
validateAttendees(event: any, places: Record<string, number>, attendees: unknown, existing: Record<string, number>): any[];
|
||||
createAttendees(registrationId: string, attendees: any[]): Promise<unknown>;
|
||||
namedPlacesByKey(registrationId: string): Promise<Record<string, number>>;
|
||||
};
|
||||
const { findAccountByContact } = require('../utils/accountMatch') as { findAccountByContact(c: { email?: string; phone?: string }): Promise<{ user: { id: string } | null; conflict?: boolean }> };
|
||||
const { ensureConsentRequests } = require('../utils/consent') as { ensureConsentRequests(registrationId: string): Promise<unknown> };
|
||||
const { takenCount } = require('../utils/stock') as { takenCount(where: object, opts?: { excludeEntryId?: string | null }): Promise<number> };
|
||||
const waitlist = require('../utils/waitlist') as { findOfferByToken(token: string): Promise<any>; queueWaitlist(eventId: string): void };
|
||||
const { reservedOptionIds } = require('../utils/volunteers') as { reservedOptionIds(event: any): string[] };
|
||||
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles') as { BUNDLE_APPS_INCLUDE: any };
|
||||
const sessions = require('../utils/sessions') as {
|
||||
registrationClosesAt(event: any, sessions: any[]): Date;
|
||||
wholeCourseClosesAt(event: any, sessions: any[]): Date;
|
||||
expandPickLines(event: any, options: any[], opts: { now: Date }): any[];
|
||||
assertSessionCapacity(event: any, lines: any[], registrationId?: string, opts?: { excludeEntryId?: string }): Promise<void>;
|
||||
mergeSessionIds(a: string[] | null | undefined, b: string[] | null | undefined): string[];
|
||||
};
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string, res?: unknown): Promise<unknown> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Role = 'admin' | 'supervisor' | 'staff' | 'user';
|
||||
export type Actor = { id: string; role: Role; email?: string } | null;
|
||||
|
||||
export const TICKET_DELIVERY = ['payer', 'each'] as const;
|
||||
export const isStaffRole = (role: string | undefined | null): boolean => ['admin', 'supervisor', 'staff'].includes(String(role));
|
||||
|
||||
// Named attendees for API responses, with whether their ticket has been issued/used.
|
||||
export const ATTENDEES_INCLUDE = {
|
||||
orderBy: { order: 'asc' as const },
|
||||
include: {
|
||||
ticket: { select: { id: true, isUsed: true } },
|
||||
// Latest consent request (youth events) — it decides check-in.
|
||||
consentRequests: { orderBy: { createdAt: 'desc' as const }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
|
||||
},
|
||||
};
|
||||
|
||||
// Active coupon on a registration, for API responses (the discount itself is
|
||||
// Registration.discountTotal).
|
||||
export const ACTIVE_COUPONS_INCLUDE = {
|
||||
where: { releasedAt: null },
|
||||
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
|
||||
};
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
export function queueConsentRequests(registrationId: string): void {
|
||||
ensureConsentRequests(registrationId).catch((e: any) => console.error('[consent] Failed to send requests:', e?.message || e));
|
||||
}
|
||||
|
||||
type Line = { eventOptionId: string; variantId?: string | null; sessionId?: string | null; quantity: number | string };
|
||||
|
||||
/**
|
||||
* Places each line will have once `requested` is added to `existingLines` — used to
|
||||
* check attendee names against what's actually being bought.
|
||||
*/
|
||||
export function placesAfter(existingLines: Line[] | undefined, requested: Line[] | undefined): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
// Picked-session lines take no attendee names.
|
||||
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
|
||||
const k = attendeesUtil.lineKey(l.eventOptionId, l.variantId);
|
||||
out[k] = (out[k] || 0) + Number(l.quantity || 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
||||
* before anything is created. Mirrors buildCouponLines for one tranche per option.
|
||||
*/
|
||||
export function pendingCouponLines(event: { eventOptions: { id: string }[] }, resolvedOptions: any[]): any[] {
|
||||
return coupons.buildCouponLines({
|
||||
registrationOptions: resolvedOptions.map(o => ({
|
||||
eventOptionId: o.eventOptionId,
|
||||
variantId: o.variantId || null,
|
||||
quantity: o.quantity,
|
||||
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
|
||||
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
|
||||
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
|
||||
*/
|
||||
export async function priceRequestedOption(eventOption: any, variantId: string | null, qty: number, buyer: any): Promise<{ priceSnapshot: number; appliedTierId: string | null }> {
|
||||
try {
|
||||
const resolved = variantId
|
||||
? await pricing.resolveVariantTierPrice(eventOption, variantId, qty, buyer)
|
||||
: await pricing.resolveOptionPrice(eventOption, qty, buyer);
|
||||
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
|
||||
} catch {
|
||||
const variant = variantId ? (eventOption.variants || []).find((v: any) => v.id === variantId) : null;
|
||||
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
|
||||
return { priceSnapshot: Number(price || 0), appliedTierId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check overall stock availability for an EventOption. Places held for live waitlist
|
||||
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
||||
*/
|
||||
export async function checkOptionStock(option: { id: string; stockLimit: number }, requestedQty: number, excludeEntryId: string | null = null, sessionId: string | null = null) {
|
||||
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null as number | null };
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
/** Check stock availability for a specific variant (same rules as checkOptionStock). */
|
||||
export async function checkVariantStock(variant: { id: string; eventOptionId: string; stockLimit: number | null }, requestedQty: number, excludeEntryId: string | null = null, sessionId: string | null = null) {
|
||||
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null as number | null };
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
export type RequestedOption = { eventOptionId: string; quantity: number; variantId?: string | null; sessionIds?: string[] };
|
||||
export type CreateRegistrationInput = {
|
||||
eventId: string;
|
||||
options: RequestedOption[];
|
||||
guestName?: string;
|
||||
guestEmail?: string;
|
||||
guestPhone?: string;
|
||||
couponCode?: string;
|
||||
attendees?: unknown[];
|
||||
ticketDelivery?: string;
|
||||
waitlistToken?: string;
|
||||
analyticsSessionId?: string;
|
||||
};
|
||||
|
||||
const FULL_INCLUDE = {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Registers someone for an event (online, a guest for a free event, or with a waitlist link):
|
||||
* checks the event is open, prices each line (early-bird, variant, staff price), checks stock
|
||||
* and session places, adds to an existing registration for the same person and event, applies
|
||||
* bundle deals and a coupon, and issues tickets straight away when nothing is owed.
|
||||
*/
|
||||
export async function createRegistration(input: CreateRegistrationInput, actor: Actor) {
|
||||
try {
|
||||
return await create(input, actor);
|
||||
} catch (e: any) {
|
||||
// The helpers (coupons, attendee names, session places…) throw plain Errors written for
|
||||
// people; like v1, those are the caller's problem (400) unless they carry a status.
|
||||
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'Registration failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function create(input: CreateRegistrationInput, actor: Actor) {
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = input;
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } }, sessions: true },
|
||||
});
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
if (!event.isActive) return fail(400, 'Event is not active');
|
||||
if (event.cashupStatus === 'closed') return fail(400, 'This event is closed and no longer accepting registrations.');
|
||||
|
||||
// A waitlist claim link: its token identifies the person and releases the places
|
||||
// held for them. It works without logging in, the same way a consent link does.
|
||||
let waitlistOffer: any = null;
|
||||
if (waitlistToken) {
|
||||
waitlistOffer = await waitlist.findOfferByToken(waitlistToken);
|
||||
if (!waitlistOffer || waitlistOffer.eventId !== eventId) fail(400, 'This waitlist link is not valid.');
|
||||
if (waitlistOffer.status !== 'offered') fail(400, `This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`);
|
||||
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) fail(400, 'This waitlist offer has expired.');
|
||||
if (actor && actor.id !== waitlistOffer.userId) fail(403, 'This waitlist offer belongs to someone else.');
|
||||
}
|
||||
|
||||
// Enforce auth unless event explicitly opts out
|
||||
if (event.requiresAuth !== false && !actor && !waitlistOffer) fail(401, 'Authentication required for this event');
|
||||
|
||||
// Unauthenticated guest registration is only permitted for free events
|
||||
if (!actor && !waitlistOffer) {
|
||||
const requestedTotal = (options || []).reduce((sum, opt) => {
|
||||
const eo = event.eventOptions.find(o => o.id === opt.eventOptionId);
|
||||
return sum + ((eo?.price || 0) * (opt.quantity || 1));
|
||||
}, 0);
|
||||
if (requestedTotal > 0) fail(400, 'Guest registration is only available for free events. Please create an account to pay for tickets.');
|
||||
}
|
||||
|
||||
// Resolve userId — authenticated or guest
|
||||
let userId: string;
|
||||
if (actor) {
|
||||
userId = actor.id;
|
||||
} else if (waitlistOffer) {
|
||||
userId = waitlistOffer.userId;
|
||||
} else {
|
||||
// Guest registration: find existing user by email or create inactive placeholder
|
||||
if (!guestEmail || !guestName) return fail(400, 'Name and email are required for unauthenticated registration');
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) fail(409, 'This email and phone number belong to two different accounts. Please log in, or check your details.');
|
||||
if (match.user) {
|
||||
userId = match.user.id;
|
||||
} else {
|
||||
const newGuest = await prisma.user.create({
|
||||
data: { id: uuidv4(), name: guestName, email: guestEmail, password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() },
|
||||
});
|
||||
userId = newGuest.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce registration cutoff for public registrations (multi-day events close when
|
||||
// the first session starts, or later with late joining — see utils/sessions.js).
|
||||
const now = new Date();
|
||||
if (now >= sessions.registrationClosesAt(event, event.sessions)) fail(400, 'Registration is closed for this event');
|
||||
|
||||
if (!options || !Array.isArray(options) || options.length === 0) fail(400, 'At least one option must be selected');
|
||||
|
||||
// "Pick sessions" options become one line per chosen session; whole-course tickets
|
||||
// on such an event still close when the whole course does.
|
||||
const lines = sessions.expandPickLines(event, options, { now });
|
||||
// (An all-sessions ticket with sessions ticked is checked per session instead.)
|
||||
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && now >= sessions.wholeCourseClosesAt(event, event.sessions)) {
|
||||
fail(400, 'Whole-course tickets are no longer on sale — choose the sessions you want instead.');
|
||||
}
|
||||
await sessions.assertSessionCapacity(event, lines, undefined, { excludeEntryId: waitlistOffer?.id });
|
||||
|
||||
// Validate each option and resolve prices / check stock. Options are independent of each
|
||||
// other, so resolve them concurrently. The kids check-in and volunteer tickets are handed
|
||||
// out by staff, never sold.
|
||||
const reserved = new Set(isStaffRole(actor?.role) ? [] : reservedOptionIds(event));
|
||||
const buyerFor = async () => actor || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
|
||||
const resolvedOptions = await Promise.all(lines.map(async (option: any) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption || reserved.has(eventOption.id)) fail(400, `Option with ID ${option.eventOptionId} not found for this event`);
|
||||
if (!option.quantity || option.quantity < 1) fail(400, 'Quantity must be at least 1');
|
||||
const qty = option.quantity;
|
||||
|
||||
// (A failing count doesn't block the sale — only a real shortage does.)
|
||||
const stockCheck = await checkOptionStock(eventOption!, qty, waitlistOffer?.id, option.sessionId).catch(() => ({ available: true, remaining: null }));
|
||||
if (!stockCheck.available) fail(400, `"${eventOption!.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
|
||||
const variantId: string | null = option.variantId || null;
|
||||
if (variantId) {
|
||||
const variant = (eventOption!.variants || []).find(v => v.id === variantId);
|
||||
if (!variant) return fail(400, `Variant not found for option "${eventOption!.name}"`);
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId).catch(() => ({ available: true, remaining: null }));
|
||||
if (!vStock.available) fail(400, `Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
}
|
||||
|
||||
// Variant-level tiers take priority, then option-level tiers, then base prices, then
|
||||
// the staff price for Staff and above.
|
||||
const { priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, await buyerFor());
|
||||
return { ...option, variantId, appliedTierId, priceSnapshot: priceSnapshot ?? eventOption!.price };
|
||||
}));
|
||||
|
||||
// Check a coupon code before anything is saved, so a bad code doesn't leave a
|
||||
// half-made registration behind. It's applied (and re-checked under a lock) below.
|
||||
if (couponCode) {
|
||||
const buyer = actor ? { id: actor.id, role: actor.role, email: actor.email } : null;
|
||||
await coupons.previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
|
||||
}
|
||||
|
||||
// Check for existing non-cancelled registration for this user+event → merge instead
|
||||
const existingReg = await prisma.registration.findFirst({
|
||||
where: { userId, eventId, status: { not: 'cancelled' } },
|
||||
include: { registrationOptions: true },
|
||||
});
|
||||
|
||||
// Attendee names are checked against the places the registration will have after
|
||||
// this purchase, counting names already given on an existing registration.
|
||||
const cleanAttendees = attendeesUtil.validateAttendees(
|
||||
event,
|
||||
placesAfter(existingReg?.registrationOptions, resolvedOptions),
|
||||
attendees,
|
||||
existingReg ? await attendeesUtil.namedPlacesByKey(existingReg.id) : {},
|
||||
);
|
||||
if (ticketDelivery !== undefined && !(TICKET_DELIVERY as readonly string[]).includes(ticketDelivery)) fail(400, "ticketDelivery must be 'payer' or 'each'");
|
||||
|
||||
let registrationId: string;
|
||||
let isNewRegistration = false;
|
||||
if (existingReg) {
|
||||
// Merge: add a new price tranche per requested option into the existing registration.
|
||||
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
|
||||
// tickets bought at different early-bird prices into a single (wrong) price. Each
|
||||
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
|
||||
for (const opt of resolvedOptions) {
|
||||
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null),
|
||||
);
|
||||
if (existing) {
|
||||
await prisma.$transaction([
|
||||
prisma.registrationOptionTranche.create({
|
||||
data: { id: uuidv4(), registrationOptionId: existing.id, quantity: opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null },
|
||||
}),
|
||||
prisma.registrationOption.update({
|
||||
where: { id: existing.id },
|
||||
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: sessions.mergeSessionIds(existing.sessionIds, opt.sessionIds) }) },
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await prisma.registrationOption.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
registrationId: existingReg.id,
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
sessionIds: opt.sessionIds || [],
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: { create: [{ id: uuidv4(), quantity: opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Only downgrade from paid if the newly added items actually cost something
|
||||
const newItemsCost = resolvedOptions.reduce((sum, opt) => sum + ((opt.priceSnapshot || 0) * (opt.quantity || 0)), 0);
|
||||
await prisma.registration.update({
|
||||
where: { id: existingReg.id },
|
||||
data: existingReg.status === 'paid' && newItemsCost > 0 ? { status: 'partial_paid', updatedAt: new Date() } : { updatedAt: new Date() },
|
||||
});
|
||||
registrationId = existingReg.id;
|
||||
} else {
|
||||
isNewRegistration = true;
|
||||
registrationId = uuidv4();
|
||||
await prisma.registration.create({
|
||||
data: {
|
||||
id: registrationId,
|
||||
userId,
|
||||
eventId,
|
||||
updatedAt: new Date(),
|
||||
registrationOptions: {
|
||||
create: resolvedOptions.map(option => ({
|
||||
id: uuidv4(),
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
sessionIds: option.sessionIds || [],
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: { create: [{ id: uuidv4(), quantity: option.quantity, priceSnapshot: option.priceSnapshot, appliedTierId: option.appliedTierId || null }] },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (cleanAttendees.length > 0) {
|
||||
await attendeesUtil.createAttendees(registrationId, cleanAttendees);
|
||||
queueConsentRequests(registrationId);
|
||||
}
|
||||
if (waitlistOffer) {
|
||||
await prisma.waitlistEntry.update({
|
||||
where: { id: waitlistOffer.id },
|
||||
data: { status: 'claimed', claimedAt: new Date(), registrationId, offerTokenHash: null },
|
||||
});
|
||||
}
|
||||
if (ticketDelivery) {
|
||||
await prisma.registration.update({ where: { id: registrationId }, data: { ticketDelivery: ticketDelivery as 'payer' | 'each' } });
|
||||
}
|
||||
|
||||
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
|
||||
let couponError: string | null = null;
|
||||
await coupons.recalculateRegistrationDiscount(registrationId);
|
||||
if (couponCode) {
|
||||
try {
|
||||
await coupons.applyCouponToRegistration({ registrationId, code: couponCode });
|
||||
} catch (e: any) {
|
||||
// Lost a race for the last use since the check above — keep the registration.
|
||||
if (!e.isCouponError) throw e;
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
const load = () => prisma.registration.findUniqueOrThrow({
|
||||
where: { id: registrationId },
|
||||
include: { ...FULL_INCLUDE, couponRedemptions: ACTIVE_COUPONS_INCLUDE, bundleApplications: BUNDLE_APPS_INCLUDE, attendees: ATTENDEES_INCLUDE },
|
||||
});
|
||||
let registration = await load();
|
||||
|
||||
// Nothing owed: mark paid and issue tickets (unless a required form comes first).
|
||||
let emailTicketsAfter = false;
|
||||
try {
|
||||
if (pricing.computeRegistrationTotalDue(registration, new Date()) === 0) {
|
||||
if (registration.status !== 'paid') {
|
||||
await prisma.registration.update({ where: { id: registrationId }, data: { status: 'paid', updatedAt: new Date() } });
|
||||
}
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } }).catch(() => null);
|
||||
if (!(form && form.isRequired)) {
|
||||
// Same generator as paid registrations: a ticket per named attendee, and one
|
||||
// shared quantity ticket for the rest of each line.
|
||||
await generateTicketsForRegistration(registrationId);
|
||||
emailTicketsAfter = true;
|
||||
}
|
||||
registration = { ...registration, status: 'paid' };
|
||||
}
|
||||
} catch {
|
||||
// ignore errors in auto-generation path; manual generation remains available
|
||||
}
|
||||
|
||||
// Fire-and-forget: registration email first, then tickets (guarantees order)
|
||||
const notifications = require('../utils/notifications') as { sendRegistrationEmails(id: string): Promise<unknown>; sendRegistrationUpdatedEmails(id: string): Promise<unknown> };
|
||||
const ownerId = registration.userId;
|
||||
void (async () => {
|
||||
try {
|
||||
if (isNewRegistration) await notifications.sendRegistrationEmails(registrationId);
|
||||
else await notifications.sendRegistrationUpdatedEmails(registrationId);
|
||||
} catch (e) { console.error('Failed to send registration emails:', e); }
|
||||
if (emailTicketsAfter) {
|
||||
try {
|
||||
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: unknown, res: unknown): Promise<unknown> };
|
||||
const res: { status(): unknown; json(): void } = { status: () => res, json: () => {} };
|
||||
await emailTickets({ user: { id: ownerId }, body: { registrationId } }, res);
|
||||
} catch (e) { console.error('Error emailing tickets for free registration:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Funnel: this registration came from a tracked (cookie-consented) visit.
|
||||
if (analyticsSessionId) {
|
||||
const { recordSubmit } = require('../utils/analytics') as { recordSubmit(reg: unknown, sessionId: string): Promise<unknown> };
|
||||
recordSubmit(registration, analyticsSessionId).catch((e: any) => console.warn('[analytics] submit not recorded:', e?.message || e));
|
||||
}
|
||||
|
||||
return { registration: pricing.attachComputedTotals(registration), isNew: isNewRegistration, couponError };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a registration: the owner (not after the event, not once anything is paid) or an
|
||||
* admin. Releases its coupon and offers its places to the waitlist.
|
||||
*/
|
||||
export async function cancelRegistration(registrationId: string, actor: { id: string; role: Role }, ip?: string) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: { event: { select: { endDate: true } } },
|
||||
});
|
||||
if (!registration) return fail(404, 'Registration not found');
|
||||
|
||||
if (registration.userId !== actor.id && actor.role !== 'admin') fail(403, 'Not authorized to cancel this registration');
|
||||
|
||||
// If event is in the past, block self-cancellation (mirrors the edit block)
|
||||
if (actor.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
|
||||
fail(400, 'This event has already ended; registration can no longer be cancelled');
|
||||
}
|
||||
|
||||
try {
|
||||
await assertEventOpen(registration.eventId);
|
||||
} catch (e: any) {
|
||||
throw new ServiceError(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
|
||||
if (registration.status === 'cancelled') fail(400, 'Registration is already cancelled');
|
||||
|
||||
// Non-admins cannot cancel a registration that has payments against it
|
||||
if (actor.role !== 'admin') {
|
||||
const paymentCount = await prisma.payment.count({ where: { registrationId: registration.id, amount: { gt: 0 } } });
|
||||
if (paymentCount > 0) fail(403, 'This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.');
|
||||
}
|
||||
|
||||
const updated = await prisma.registration.update({ where: { id: registrationId }, data: { status: 'cancelled', updatedAt: new Date() } });
|
||||
await coupons.releaseRegistrationCoupons(registrationId);
|
||||
waitlist.queueWaitlist(registration.eventId);
|
||||
|
||||
// Only log when staff cancels on someone else's behalf — a routine self-service
|
||||
// cancellation isn't an admin action worth cluttering the audit trail with.
|
||||
if (registration.userId !== actor.id) {
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'registration_cancelled', targetType: 'Registration', targetId: registrationId,
|
||||
metadata: { registrationOwnerId: registration.userId }, ip,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import auth from '../middleware/authMiddleware';
|
||||
// Resources: importing one registers its routes.
|
||||
import './resources/events';
|
||||
import './resources/me';
|
||||
import './resources/registrations';
|
||||
|
||||
const pkg = require('../../package.json') as { version: string };
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import { z } from 'zod';
|
||||
import { defineRoute } from '../route';
|
||||
import { ApiError } from '../errors';
|
||||
import { pageQuery, paged, single, skipTake, pageOf } from '../paging';
|
||||
import { eventRef, id, iso, isoDate } from '../schemas';
|
||||
import { prisma, computeRegistrationTotalDue } from '../legacy';
|
||||
import { id, iso, isoDate } from '../schemas';
|
||||
import { prisma } from '../legacy';
|
||||
import { registrationSummary, SUMMARY_INCLUDE, toSummary } from './registrations';
|
||||
|
||||
const me = z.object({
|
||||
id,
|
||||
@@ -32,26 +33,6 @@ export const getMe = defineRoute({
|
||||
},
|
||||
});
|
||||
|
||||
const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const;
|
||||
|
||||
const myRegistration = z.object({
|
||||
id,
|
||||
status: z.enum(STATUSES),
|
||||
createdAt: isoDate,
|
||||
event: eventRef,
|
||||
items: z.array(z.object({
|
||||
ticket: z.string(),
|
||||
variant: z.string().nullable(),
|
||||
quantity: z.number().int(),
|
||||
unitPrice: z.number(),
|
||||
})),
|
||||
totalDue: z.number().describe('In rand, after discounts'),
|
||||
amountPaid: z.number().describe('In rand (refunds subtracted)'),
|
||||
balance: z.number().describe('totalDue − amountPaid; 0 or less = nothing owing'),
|
||||
});
|
||||
|
||||
const r2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
export const listMyRegistrations = defineRoute({
|
||||
method: 'get',
|
||||
path: '/me/registrations',
|
||||
@@ -62,38 +43,16 @@ export const listMyRegistrations = defineRoute({
|
||||
query: pageQuery.extend({
|
||||
past: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include past and cancelled registrations'),
|
||||
}),
|
||||
response: paged(myRegistration),
|
||||
response: paged(registrationSummary),
|
||||
async handler({ query, user }) {
|
||||
const now = new Date();
|
||||
const where = query.past
|
||||
? { userId: user!.id }
|
||||
: { userId: user!.id, status: { not: 'cancelled' as const }, event: { endDate: { gte: now } } };
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.registration.findMany({
|
||||
where,
|
||||
include: {
|
||||
event: { select: { id: true, title: true, startDate: true, endDate: true } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { name: true, price: true } }, tranches: true } },
|
||||
payments: { select: { amount: true, createdAt: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
...skipTake(query),
|
||||
}),
|
||||
prisma.registration.findMany({ where, include: SUMMARY_INCLUDE, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], ...skipTake(query) }),
|
||||
prisma.registration.count({ where }),
|
||||
]);
|
||||
return pageOf(rows.map(r => {
|
||||
const totalDue = r2(computeRegistrationTotalDue(r, now));
|
||||
const amountPaid = r2(r.payments.reduce((s, p) => s + p.amount, 0));
|
||||
return {
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
createdAt: iso(r.createdAt),
|
||||
event: { id: r.event.id, title: r.event.title, startDate: iso(r.event.startDate), endDate: iso(r.event.endDate) },
|
||||
items: r.registrationOptions.map(o => ({ ticket: o.eventOption?.name ?? '', variant: o.variant?.name ?? null, quantity: o.quantity, unitPrice: o.priceSnapshot ?? o.eventOption?.price ?? 0 })),
|
||||
totalDue,
|
||||
amountPaid,
|
||||
balance: r2(totalDue - amountPaid),
|
||||
};
|
||||
}), total, query);
|
||||
return pageOf(rows.map(r => toSummary(r, now)), total, query);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { z } from 'zod';
|
||||
import { defineRoute, type ApiUser } from '../route';
|
||||
import { ApiError, notFound } from '../errors';
|
||||
import { single } from '../paging';
|
||||
import { eventRef, id, idParams, iso, isoDate } from '../schemas';
|
||||
import { prisma, computeRegistrationTotalDue } from '../legacy';
|
||||
import { createRegistration, cancelRegistration } from '../../services/registrations';
|
||||
import { startRegistrationCheckout } from '../../services/payments';
|
||||
|
||||
const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const;
|
||||
|
||||
/** A registration as v2 shows it to its owner. Also used by GET /me/registrations. */
|
||||
export const registrationSummary = z.object({
|
||||
id,
|
||||
status: z.enum(STATUSES),
|
||||
createdAt: isoDate,
|
||||
event: eventRef,
|
||||
items: z.array(z.object({
|
||||
ticket: z.string(),
|
||||
variant: z.string().nullable(),
|
||||
quantity: z.number().int(),
|
||||
unitPrice: z.number(),
|
||||
})),
|
||||
totalDue: z.number().describe('In rand, after discounts'),
|
||||
amountPaid: z.number().describe('In rand (refunds subtracted)'),
|
||||
balance: z.number().describe('totalDue − amountPaid; 0 or less = nothing owing'),
|
||||
});
|
||||
|
||||
const r2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
export const SUMMARY_INCLUDE = {
|
||||
event: { select: { id: true, title: true, startDate: true, endDate: true } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { name: true, price: true } }, tranches: true } },
|
||||
payments: { select: { amount: true, createdAt: true } },
|
||||
} as const;
|
||||
|
||||
type SummaryRow = {
|
||||
id: string; status: (typeof STATUSES)[number]; createdAt: Date;
|
||||
event: { id: string; title: string; startDate: Date; endDate: Date };
|
||||
registrationOptions: { quantity: number; priceSnapshot: number | null; eventOption: { name: string; price: number } | null; variant: { name: string } | null }[];
|
||||
payments: { amount: number }[];
|
||||
};
|
||||
|
||||
export function toSummary(r: SummaryRow, now = new Date()): z.input<typeof registrationSummary> {
|
||||
const totalDue = r2(computeRegistrationTotalDue(r, now));
|
||||
const amountPaid = r2(r.payments.reduce((s, p) => s + p.amount, 0));
|
||||
return {
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
createdAt: iso(r.createdAt),
|
||||
event: { id: r.event.id, title: r.event.title, startDate: iso(r.event.startDate), endDate: iso(r.event.endDate) },
|
||||
items: r.registrationOptions.map(o => ({ ticket: o.eventOption?.name ?? '', variant: o.variant?.name ?? null, quantity: o.quantity, unitPrice: o.priceSnapshot ?? o.eventOption?.price ?? 0 })),
|
||||
totalDue,
|
||||
amountPaid,
|
||||
balance: r2(totalDue - amountPaid),
|
||||
};
|
||||
}
|
||||
|
||||
/** The owner's registration (admins may see anyone's); others get NOT_FOUND, not a hint it exists. */
|
||||
async function ownRegistration(registrationId: string, user: ApiUser) {
|
||||
const r = await prisma.registration.findUnique({ where: { id: registrationId }, include: SUMMARY_INCLUDE });
|
||||
if (!r || (r.userId !== user.id && user.role !== 'admin')) throw notFound('That registration');
|
||||
return r;
|
||||
}
|
||||
|
||||
const attendee = z.object({
|
||||
eventOptionId: id,
|
||||
variantId: id.nullish(),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
email: z.string().trim().max(200).nullish(),
|
||||
phone: z.string().trim().max(40).nullish(),
|
||||
dateOfBirth: z.string().max(40).nullish().describe('YYYY-MM-DD (youth events)'),
|
||||
gender: z.string().max(20).nullish(),
|
||||
parentName: z.string().max(200).nullish(),
|
||||
parentEmail: z.string().max(200).nullish(),
|
||||
parentPhone: z.string().max(40).nullish(),
|
||||
});
|
||||
|
||||
export const register = defineRoute({
|
||||
method: 'post',
|
||||
path: '/registrations',
|
||||
tag: 'Registrations',
|
||||
summary: 'Register for an event',
|
||||
description: [
|
||||
'Signed in, or as a guest for a free event that allows it (`guest` then required), or with a waitlist link (`waitlistToken`).',
|
||||
'Registering again for the same event adds to the existing registration. When nothing is owed it is marked paid and tickets are issued; otherwise pay with `POST /registrations/{id}/checkout`.',
|
||||
'A coupon that was valid when checked but used up a moment later is reported in `couponError`; the registration is still made.',
|
||||
].join('\n\n'),
|
||||
access: 'optional',
|
||||
status: 201,
|
||||
body: z.object({
|
||||
eventId: id,
|
||||
options: z.array(z.object({
|
||||
eventOptionId: id,
|
||||
quantity: z.number().int().min(1).max(1000),
|
||||
variantId: id.nullish(),
|
||||
sessionIds: z.array(id).max(200).optional().describe('"Pick sessions" tickets: the sessions chosen'),
|
||||
})).min(1).max(50),
|
||||
guest: z.object({ name: z.string().trim().min(1).max(200), email: z.email().max(200), phone: z.string().trim().max(40).optional() }).optional(),
|
||||
couponCode: z.string().trim().max(60).optional(),
|
||||
attendees: z.array(attendee).max(500).optional().describe('Names for the places (events that collect them)'),
|
||||
ticketDelivery: z.enum(['payer', 'each']).optional().describe('With attendees: tickets to the payer, or to each attendee'),
|
||||
waitlistToken: z.string().max(200).optional(),
|
||||
analyticsSessionId: z.string().max(100).optional(),
|
||||
}),
|
||||
response: single(registrationSummary.extend({
|
||||
isNew: z.boolean().describe('false = added to an existing registration'),
|
||||
couponError: z.string().nullable(),
|
||||
})),
|
||||
async handler({ body, user }) {
|
||||
const { registration, isNew, couponError } = await createRegistration({
|
||||
eventId: body.eventId,
|
||||
options: body.options.map(o => ({ ...o, variantId: o.variantId ?? null })),
|
||||
guestName: body.guest?.name,
|
||||
guestEmail: body.guest?.email,
|
||||
guestPhone: body.guest?.phone,
|
||||
couponCode: body.couponCode,
|
||||
attendees: body.attendees,
|
||||
ticketDelivery: body.ticketDelivery,
|
||||
waitlistToken: body.waitlistToken,
|
||||
analyticsSessionId: body.analyticsSessionId,
|
||||
}, user ? { id: user.id, role: user.role, email: user.email } : null);
|
||||
const fresh = await prisma.registration.findUniqueOrThrow({ where: { id: registration.id }, include: SUMMARY_INCLUDE });
|
||||
return { data: { ...toSummary(fresh), isNew, couponError } };
|
||||
},
|
||||
});
|
||||
|
||||
export const getRegistration = defineRoute({
|
||||
method: 'get',
|
||||
path: '/registrations/:id',
|
||||
tag: 'Registrations',
|
||||
summary: 'One of my registrations',
|
||||
access: 'user',
|
||||
params: idParams,
|
||||
response: single(registrationSummary),
|
||||
async handler({ params, user }) {
|
||||
return { data: toSummary(await ownRegistration(params.id, user!)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const cancel = defineRoute({
|
||||
method: 'post',
|
||||
path: '/registrations/:id/cancel',
|
||||
tag: 'Registrations',
|
||||
summary: 'Cancel my registration',
|
||||
description: 'Not after the event has ended, and not once anything has been paid (the church can do that). Its places go to the waitlist.',
|
||||
access: 'user',
|
||||
params: idParams,
|
||||
response: single(registrationSummary),
|
||||
async handler({ params, user, req }) {
|
||||
await ownRegistration(params.id, user!);
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
await cancelRegistration(params.id, user!, getClientIp(req));
|
||||
return { data: toSummary(await ownRegistration(params.id, user!)) };
|
||||
},
|
||||
});
|
||||
|
||||
export const checkout = defineRoute({
|
||||
method: 'post',
|
||||
path: '/registrations/:id/checkout',
|
||||
tag: 'Registrations',
|
||||
summary: 'Pay what I owe online',
|
||||
description: [
|
||||
'Starts a payment with the church\'s provider (Yoco or PayFast) and returns where to send the person (`redirectUrl`).',
|
||||
'If an early-bird price ran out since the registration was made, nothing is started: `priceUpdated` is true with the new amount owing — show it, then call again.',
|
||||
].join('\n\n'),
|
||||
access: 'user',
|
||||
params: idParams,
|
||||
body: z.object({
|
||||
donationAmount: z.number().min(0).max(100000).optional().describe('An optional donation added on top, in rand'),
|
||||
provider: z.string().max(40).optional().describe('yoco or payfast (default: the first one switched on)'),
|
||||
successUrl: z.url().max(2000).optional(),
|
||||
cancelUrl: z.url().max(2000).optional(),
|
||||
failureUrl: z.url().max(2000).optional(),
|
||||
}),
|
||||
response: single(z.discriminatedUnion('priceUpdated', [
|
||||
z.object({ priceUpdated: z.literal(true), newBalance: z.number(), message: z.string() }),
|
||||
z.object({ priceUpdated: z.literal(false), checkoutId: z.string(), redirectUrl: z.string(), provider: z.string(), amount: z.number().describe('Charged now, in rand (with any donation)') }),
|
||||
])),
|
||||
async handler({ params, body, user }) {
|
||||
await ownRegistration(params.id, user!);
|
||||
const result = await startRegistrationCheckout(params.id, user!, body);
|
||||
if (result.priceUpdated) return { data: { priceUpdated: true as const, newBalance: result.newTotal, message: result.message } };
|
||||
if (!result.redirectUrl) throw new ApiError('INTERNAL', 'The payment provider did not return a payment page.');
|
||||
return { data: { priceUpdated: false as const, checkoutId: result.checkoutId, redirectUrl: result.redirectUrl, provider: result.provider, amount: result.amount } };
|
||||
},
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import request from 'supertest';
|
||||
const mockDb = {
|
||||
event: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
registration: { findMany: jest.fn(), count: jest.fn() },
|
||||
registration: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), findUniqueOrThrow: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/campuses', () => ({
|
||||
@@ -28,7 +28,13 @@ jest.mock('../src/middleware/authMiddleware', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// The services are tested through v1 (e2e); here only how v2 calls them and maps their answers.
|
||||
const mockServices = { createRegistration: jest.fn(), cancelRegistration: jest.fn(), startRegistrationCheckout: jest.fn() };
|
||||
jest.mock('../src/services/registrations', () => ({ createRegistration: (...a: unknown[]) => mockServices.createRegistration(...a), cancelRegistration: (...a: unknown[]) => mockServices.cancelRegistration(...a) }));
|
||||
jest.mock('../src/services/payments', () => ({ startRegistrationCheckout: (...a: unknown[]) => mockServices.startRegistrationCheckout(...a) }));
|
||||
|
||||
import { v2Router } from '../src/v2';
|
||||
import { ServiceError } from '../src/services/errors';
|
||||
import { routes } from '../src/v2/route';
|
||||
import { buildOpenApi } from '../src/v2/openapi';
|
||||
|
||||
@@ -150,3 +156,84 @@ describe('the API description', () => {
|
||||
expect(res.body.error.code).toBe('NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registrations', () => {
|
||||
const row = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'r1', userId: 'u1', status: 'pending', createdAt: new Date('2026-10-01T10:00:00Z'), discountTotal: 0,
|
||||
event: { id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z') },
|
||||
registrationOptions: [{ quantity: 1, priceSnapshot: 600, eventOption: { name: 'Camper', price: 600, earlyBirdTiers: [] }, variant: null, tranches: [] }],
|
||||
payments: [], ...over,
|
||||
});
|
||||
|
||||
test('registering passes a guest through and answers 201 with what is owing', async () => {
|
||||
mockServices.createRegistration.mockResolvedValue({ registration: { id: 'r1' }, isNew: true, couponError: null });
|
||||
mockDb.registration.findUniqueOrThrow.mockResolvedValue(row());
|
||||
const res = await request(app).post('/api/v2/registrations').send({
|
||||
eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }], guest: { name: 'Thandi', email: 'thandi@example.com' },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.data).toMatchObject({ id: 'r1', isNew: true, couponError: null, totalDue: 600, balance: 600 });
|
||||
expect(mockServices.createRegistration).toHaveBeenCalledWith(expect.objectContaining({ eventId: 'e1', guestName: 'Thandi', guestEmail: 'thandi@example.com' }), null);
|
||||
});
|
||||
|
||||
test('a bad body never reaches the service', async () => {
|
||||
const res = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 0 }], guest: { name: 'T', email: 'not-an-email' } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(Object.keys(res.body.error.fields).sort()).toEqual(['guest.email', 'options.0.quantity']);
|
||||
expect(mockServices.createRegistration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("the service's errors keep their meaning", async () => {
|
||||
mockServices.createRegistration.mockRejectedValueOnce(new ServiceError(401, 'Authentication required for this event'));
|
||||
const res = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }] });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toEqual({ code: 'UNAUTHENTICATED', message: 'Authentication required for this event' });
|
||||
|
||||
mockServices.createRegistration.mockRejectedValueOnce(new ServiceError(400, '"Camper" is sold out or does not have enough stock (0 remaining).'));
|
||||
const sold = await request(app).post('/api/v2/registrations').send({ eventId: 'e1', options: [{ eventOptionId: 'o1', quantity: 1 }] });
|
||||
expect(sold.body.error.code).toBe('VALIDATION_FAILED');
|
||||
});
|
||||
|
||||
test("someone else's registration is NOT_FOUND, and can't be cancelled or paid", async () => {
|
||||
signedIn = { id: 'u2', role: 'user' };
|
||||
mockDb.registration.findUnique.mockResolvedValue(row());
|
||||
for (const [method, path] of [['get', '/api/v2/registrations/r1'], ['post', '/api/v2/registrations/r1/cancel'], ['post', '/api/v2/registrations/r1/checkout']] as const) {
|
||||
const res = await request(app)[method](path).send({});
|
||||
expect([path, res.status]).toEqual([path, 404]);
|
||||
}
|
||||
expect(mockServices.cancelRegistration).not.toHaveBeenCalled();
|
||||
expect(mockServices.startRegistrationCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cancelling my registration', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockDb.registration.findUnique.mockResolvedValueOnce(row()).mockResolvedValueOnce(row({ status: 'cancelled' }));
|
||||
mockServices.cancelRegistration.mockResolvedValue({});
|
||||
const res = await request(app).post('/api/v2/registrations/r1/cancel');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
test('checkout: the payment page, or the new balance when an early-bird price ran out', async () => {
|
||||
signedIn = { id: 'u1', role: 'user' };
|
||||
mockDb.registration.findUnique.mockResolvedValue(row());
|
||||
mockServices.startRegistrationCheckout.mockResolvedValueOnce({ checkoutId: 'c1', redirectUrl: 'https://pay.example/c1', provider: 'yoco', amount: 650, registration: {} });
|
||||
const pay = await request(app).post('/api/v2/registrations/r1/checkout').send({ donationAmount: 50 });
|
||||
expect(pay.body.data).toEqual({ priceUpdated: false, checkoutId: 'c1', redirectUrl: 'https://pay.example/c1', provider: 'yoco', amount: 650 });
|
||||
expect(mockServices.startRegistrationCheckout).toHaveBeenCalledWith('r1', signedIn, { donationAmount: 50 });
|
||||
|
||||
mockServices.startRegistrationCheckout.mockResolvedValueOnce({ priceUpdated: true, newTotal: 700, message: 'Prices changed' });
|
||||
const changed = await request(app).post('/api/v2/registrations/r1/checkout').send({});
|
||||
expect(changed.body.data).toEqual({ priceUpdated: true, newBalance: 700, message: 'Prices changed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the frontend types', () => {
|
||||
test('frontend/src/lib/api-v2/openapi.json is up to date (run `npm run api:types` in the repo root)', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { OPENAPI_FILE, currentDocument } = require('../scripts/openapi');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const saved = require('fs').readFileSync(OPENAPI_FILE, 'utf8').replace(/\r\n/g, '\n');
|
||||
expect(saved).toBe(currentDocument());
|
||||
});
|
||||
});
|
||||
|
||||
+12
-4
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-29, after **1.51.0** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-10-29, after **1.51.1** (and Hub 1.5.0). The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
|
||||
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
|
||||
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
|
||||
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
|
||||
@@ -62,9 +63,9 @@ encrypted settings unreadable or break existing installs.
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **1.51.1 — v2 write endpoints** (register, pay, check-in, users; split `registrationController`
|
||||
and `eventController` into TS services as they move; typed frontend calls from the schemas), then
|
||||
**1.51.2 — the rest of v2, Express 5, v1 deprecation headers**, then **2.0.0 — AI helpers** (§15).
|
||||
1. **1.51.2 — v2 check-in and accounts** (ticket scan and manual check-in, sign up, log in,
|
||||
profile; move them into TS services as 1.51.1 did), then **1.51.3 — the rest of v2, Express 5,
|
||||
v1 deprecation headers and v2-shaped global middleware**, then **2.0.0 — AI helpers** (§15).
|
||||
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
|
||||
2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the
|
||||
licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub
|
||||
@@ -471,6 +472,13 @@ a test server (both done 2026-10-28).
|
||||
The response is parsed by its schema: unknown fields are dropped, and a mismatch is a 500 plus a
|
||||
log line (tests catch it). Staff/supervisor routes must declare `campusScope` (start-up fails
|
||||
otherwise).
|
||||
- **Services** (`src/services/*.ts`, 1.51.1): business logic both API versions call. Throw
|
||||
`ServiceError(status, message)` (`fail()`); v1 controllers keep their old answers (mostly 400
|
||||
for any error), v2 maps the status to its codes. Helper errors without a status count as 400,
|
||||
like v1. JS helpers are required with a typed signature at the top of each service.
|
||||
- **Frontend v2 calls:** `apiV2("post", "/registrations/{id}/cancel", { params: { id }, authToken })`
|
||||
(`frontend/src/lib/apiV2.ts`). After adding or changing a v2 route run `npm run api:types` in the
|
||||
repo root and commit `frontend/src/lib/api-v2/` (`tests/v2.test.ts` fails while it's stale).
|
||||
- Not yet v2-shaped: the global middleware in front of every route (rate limit, impersonation
|
||||
read-only guard, licence read-only) still answers `{ message }`. Do that with 1.51.2.
|
||||
|
||||
|
||||
+3
-2
@@ -39,7 +39,8 @@ report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound
|
||||
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
|
||||
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
|
||||
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
|
||||
foundations** shipped. **Next: 1.51.1 v2 write endpoints, then 1.51.2.**
|
||||
foundations** and 1.51.1 **v2 registrations and payments** shipped. **Next: 1.51.2 v2 check-in and
|
||||
accounts, then 1.51.3.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -1007,7 +1008,7 @@ income justify the hardware:
|
||||
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
|
||||
| 11 | 1.51.0 ✅, 1.51.1, 1.51.2 | 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 write endpoints and services, typed frontend calls; 1.51.2 the rest, Express 5, v1 deprecation headers | The breaking-change groundwork |
|
||||
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2, 1.51.3 | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 the rest, Express 5, v1 deprecation headers | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
|
||||
@@ -35,6 +35,23 @@ test.describe("API v2", () => {
|
||||
expect(regs.data.length).toBe(Math.min(25, regs.meta.total));
|
||||
});
|
||||
|
||||
test("register for a free event, then cancel it", async ({ request }) => {
|
||||
const headers = { Authorization: `Bearer ${seed().users.staff.token}` };
|
||||
const event = (await (await request.get(`${API}/api/v2/events/${seed().events.free}`)).json()).data;
|
||||
const made = await request.post(`${API}/api/v2/registrations`, { headers, data: { eventId: event.id, options: [{ eventOptionId: event.ticketOptions[0].id, quantity: 1 }] } });
|
||||
expect(made.status()).toBe(201);
|
||||
const reg = (await made.json()).data;
|
||||
expect(reg).toMatchObject({ status: "paid", balance: 0, event: { id: event.id } });
|
||||
|
||||
const someoneElse = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers: { Authorization: `Bearer ${seed().users.user.token}` } });
|
||||
expect((await someoneElse.json()).error.code).toBe("NOT_FOUND");
|
||||
|
||||
const cancelled = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers });
|
||||
expect((await cancelled.json()).data.status).toBe("cancelled");
|
||||
const again = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers });
|
||||
expect((await again.json()).error).toMatchObject({ code: "VALIDATION_FAILED", message: "Registration is already cancelled" });
|
||||
});
|
||||
|
||||
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
|
||||
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
|
||||
|
||||
Generated
+243
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
@@ -54,6 +54,7 @@
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.5.24",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "3.4",
|
||||
"typescript": "^5"
|
||||
@@ -2770,6 +2771,99 @@
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/ajv": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js-replace": "^1.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/config": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
|
||||
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core": {
|
||||
"version": "1.34.22",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.22.tgz",
|
||||
"integrity": "sha512-dEyx4uHh6Pc6ZkdU9Xe8rA4h12Odq6QKU/fqseBn07xwVwskhHiF3KHhqmw5BR+jAiFTvKTmwCafFql/k1pSzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/ajv": "8.11.2",
|
||||
"@redocly/config": "0.22.0",
|
||||
"colorette": "1.4.0",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"js-levenshtein": "1.1.6",
|
||||
"js-yaml": "4.3.2",
|
||||
"minimatch": "5.1.9",
|
||||
"pluralize": "8.0.0",
|
||||
"yaml-ast-parser": "0.0.43"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0",
|
||||
"npm": ">=9.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-commonjs": {
|
||||
"version": "28.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz",
|
||||
@@ -4650,6 +4744,16 @@
|
||||
"ajv": "^8.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -5208,6 +5312,13 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/change-case": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -5316,6 +5427,13 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -6903,6 +7021,19 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/index-to-position": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -7446,6 +7577,16 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-levenshtein": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -7498,8 +7639,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
@@ -8134,6 +8274,50 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
|
||||
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/openapi-core": "^1.34.6",
|
||||
"ansi-colors": "^4.1.3",
|
||||
"change-case": "^5.4.4",
|
||||
"parse-json": "^8.3.0",
|
||||
"supports-color": "^10.2.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-typescript": "bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.x"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -8223,6 +8407,37 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"index-to-position": "^1.1.0",
|
||||
"type-fest": "^4.39.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json/node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -8300,6 +8515,16 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
@@ -8813,7 +9038,6 @@
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10051,6 +10275,13 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js-replace": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.2.tgz",
|
||||
"integrity": "sha512-VeBBt/nMxedczht3KDL2yBBMJqIxpCQLMZp3UJ1otcWwUHdQW4IiyHpJivExzSK+FdxTowRfGL4FmwDqH9+S8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
@@ -10328,6 +10559,13 @@
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml-ast-parser": {
|
||||
"version": "0.0.43",
|
||||
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"api:types": "openapi-typescript src/lib/api-v2/openapi.json -o src/lib/api-v2/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -55,6 +56,7 @@
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.5.24",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "3.4",
|
||||
"typescript": "^5"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatDate } from "@/lib/date";
|
||||
@@ -454,10 +455,7 @@ export default function UserDashboardPage() {
|
||||
if (!token || !activeRegId) return;
|
||||
setCancelLoading(true);
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(activeRegId)}`, {
|
||||
method: 'DELETE',
|
||||
authToken: token,
|
||||
});
|
||||
await apiV2("post", "/registrations/{id}/cancel", { params: { id: activeRegId }, authToken: token });
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
setRegistrations(myRegs);
|
||||
setActiveRegId(null);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
@@ -552,6 +553,15 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
setError(`The minimum donation is R${MIN_DONATION}. Choose None to skip it.`);
|
||||
return;
|
||||
}
|
||||
// Registers through API v2; returns the new registration (balance = what's owed now).
|
||||
const register = async (extra: { couponCode?: string; waitlistToken?: string; guest?: { name: string; email: string; phone?: string } }, authToken?: string | null) => {
|
||||
const named = namedAttendees();
|
||||
const { data } = await apiV2("post", "/registrations", {
|
||||
body: { eventId, options: items, analyticsSessionId: analyticsSessionId(), ...(named.length > 0 ? { attendees: named, ticketDelivery: delivery } : {}), ...extra },
|
||||
authToken,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const donationParam = donation > 0 ? `&donation=${encodeURIComponent(donation.toFixed(2))}` : "";
|
||||
|
||||
// Claim link without logging in: the link identifies the person.
|
||||
@@ -559,11 +569,8 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
try {
|
||||
setError(null);
|
||||
setCreatingRegistration(true);
|
||||
const res = await apiFetch<{ id: string; totalDueComputed?: number }>("/api/registrations", {
|
||||
method: "POST",
|
||||
body: { eventId, options: items, waitlistToken, analyticsSessionId: analyticsSessionId(), ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
|
||||
});
|
||||
if (res?.id) window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((res.totalDueComputed ?? payable).toFixed(2))}`;
|
||||
const res = await register({ waitlistToken });
|
||||
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent(Math.max(0, res.balance).toFixed(2))}`;
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Registration failed");
|
||||
} finally {
|
||||
@@ -581,21 +588,10 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
try {
|
||||
setError(null);
|
||||
setCreatingRegistration(true);
|
||||
const res = await apiFetch<{ id: string; totalDueComputed?: number; couponError?: string }>("/api/registrations", {
|
||||
method: "POST",
|
||||
body: {
|
||||
eventId,
|
||||
options: items,
|
||||
...(coupon ? { couponCode: coupon.code } : {}),
|
||||
analyticsSessionId: analyticsSessionId(),
|
||||
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
|
||||
...(waitlistToken ? { waitlistToken } : {}),
|
||||
},
|
||||
authToken: token,
|
||||
});
|
||||
if (res?.id) {
|
||||
const res = await register({ ...(coupon ? { couponCode: coupon.code } : {}), ...(waitlistToken ? { waitlistToken } : {}) }, token);
|
||||
{
|
||||
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
|
||||
const due = res.totalDueComputed ?? payable;
|
||||
const due = Math.max(0, res.balance);
|
||||
const next = hasForm
|
||||
? `/forms?registrationId=${encodeURIComponent(res.id)}${donationParam}`
|
||||
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}${donationParam}`;
|
||||
@@ -628,15 +624,8 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
|
||||
try {
|
||||
setError(null);
|
||||
setCreatingRegistration(true);
|
||||
const res = await apiFetch<{ id: string }>("/api/registrations", {
|
||||
method: "POST",
|
||||
body: {
|
||||
eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined,
|
||||
analyticsSessionId: analyticsSessionId(),
|
||||
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
|
||||
},
|
||||
});
|
||||
if (res?.id) {
|
||||
const res = await register({ guest: { name: guestName.trim(), email: guestEmail.trim(), ...(guestPhone.trim() ? { phone: guestPhone.trim() } : {}) } });
|
||||
{
|
||||
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
|
||||
if (hasForm) {
|
||||
window.location.href = `/forms?registrationId=${encodeURIComponent(res.id)}`;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+838
@@ -0,0 +1,838 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/events": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Upcoming public events
|
||||
* @description Events anyone can see (active, not hidden, live) that have not ended, soonest first.
|
||||
*
|
||||
* **Who:** Anyone.
|
||||
*/
|
||||
get: operations["getEvents"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/events/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* One public event
|
||||
* @description With its ticket options (internal kids check-in and volunteer tickets left out) and sessions. Hidden events can be fetched by id (they are unlisted, not private).
|
||||
*
|
||||
* **Who:** Anyone.
|
||||
*/
|
||||
get: operations["getEventsId"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/registrations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Register for an event
|
||||
* @description Signed in, or as a guest for a free event that allows it (`guest` then required), or with a waitlist link (`waitlistToken`).
|
||||
*
|
||||
* Registering again for the same event adds to the existing registration. When nothing is owed it is marked paid and tickets are issued; otherwise pay with `POST /registrations/{id}/checkout`.
|
||||
*
|
||||
* A coupon that was valid when checked but used up a moment later is reported in `couponError`; the registration is still made.
|
||||
*
|
||||
* **Who:** Anyone; signed-in staff may see more.
|
||||
*/
|
||||
post: operations["postRegistrations"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/registrations/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* One of my registrations
|
||||
* @description **Who:** Any signed-in person.
|
||||
*/
|
||||
get: operations["getRegistrationsId"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/registrations/{id}/cancel": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Cancel my registration
|
||||
* @description Not after the event has ended, and not once anything has been paid (the church can do that). Its places go to the waitlist.
|
||||
*
|
||||
* **Who:** Any signed-in person.
|
||||
*/
|
||||
post: operations["postRegistrationsIdCancel"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/registrations/{id}/checkout": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Pay what I owe online
|
||||
* @description Starts a payment with the church's provider (Yoco or PayFast) and returns where to send the person (`redirectUrl`).
|
||||
*
|
||||
* If an early-bird price ran out since the registration was made, nothing is started: `priceUpdated` is true with the new amount owing — show it, then call again.
|
||||
*
|
||||
* **Who:** Any signed-in person.
|
||||
*/
|
||||
post: operations["postRegistrationsIdCheckout"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/me": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* The signed-in person
|
||||
* @description **Who:** Any signed-in person.
|
||||
*/
|
||||
get: operations["getMe"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/me/registrations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* My registrations
|
||||
* @description Newest first. By default only ones for events that have not ended, and not cancelled; `past=true` lists everything.
|
||||
*
|
||||
* **Who:** Any signed-in person.
|
||||
*/
|
||||
get: operations["getMeRegistrations"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
Error: {
|
||||
error: {
|
||||
/** @enum {string} */
|
||||
code: "VALIDATION_FAILED" | "UNAUTHENTICATED" | "FORBIDDEN" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "READ_ONLY" | "INTERNAL";
|
||||
message: string;
|
||||
/** @description Per-field problems (VALIDATION_FAILED only) */
|
||||
fields?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
getEvents: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Page number, from 1 */
|
||||
page?: number;
|
||||
/** @description Items per page (at most 100) */
|
||||
limit?: number;
|
||||
/** @description Search the title */
|
||||
q?: string;
|
||||
/** @description A campus id or slug (its events plus church-wide ones), or 'all' for church-wide events only */
|
||||
campus?: string;
|
||||
/** @description A series slug */
|
||||
series?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Format: date-time */
|
||||
startDate: string;
|
||||
/** Format: date-time */
|
||||
endDate: string;
|
||||
location: string | null;
|
||||
/** @description The headline price in rand (0 = free) */
|
||||
price: number;
|
||||
pictureUrl: string | null;
|
||||
requiresRegistration: boolean;
|
||||
/** @enum {string} */
|
||||
scheduleType: "single" | "sessions";
|
||||
/** @description null = for all campuses (or campuses are off) */
|
||||
campus: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
series: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
}[];
|
||||
meta: {
|
||||
page: number;
|
||||
limit: number;
|
||||
/** @description Items across all pages */
|
||||
total: number;
|
||||
/** @description Number of pages (at least 1) */
|
||||
pages: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getEventsId: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The id */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Format: date-time */
|
||||
startDate: string;
|
||||
/** Format: date-time */
|
||||
endDate: string;
|
||||
location: string | null;
|
||||
/** @description The headline price in rand (0 = free) */
|
||||
price: number;
|
||||
pictureUrl: string | null;
|
||||
requiresRegistration: boolean;
|
||||
/** @enum {string} */
|
||||
scheduleType: "single" | "sessions";
|
||||
/** @description null = for all campuses (or campuses are off) */
|
||||
campus: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
series: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
description: string | null;
|
||||
registrationDeadline: string | null;
|
||||
ticketOptions: {
|
||||
id: string;
|
||||
name: string;
|
||||
/** @description The normal price in rand */
|
||||
price: number;
|
||||
/** @description What it costs now (an early-bird price while one runs) */
|
||||
currentPrice: number;
|
||||
isMainTicket: boolean;
|
||||
/**
|
||||
* @description "pick" = the buyer chooses sessions
|
||||
* @enum {string}
|
||||
*/
|
||||
sessionMode: "all" | "pick";
|
||||
}[];
|
||||
/** @description Multi-day events only; empty otherwise */
|
||||
sessions: {
|
||||
id: string;
|
||||
label: string | null;
|
||||
/** Format: date-time */
|
||||
startsAt: string;
|
||||
/** Format: date-time */
|
||||
endsAt: string;
|
||||
location: string | null;
|
||||
cancelled: boolean;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not found (NOT_FOUND) */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
postRegistrations: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
eventId: string;
|
||||
options: {
|
||||
eventOptionId: string;
|
||||
quantity: number;
|
||||
variantId?: string | null;
|
||||
/** @description "Pick sessions" tickets: the sessions chosen */
|
||||
sessionIds?: string[];
|
||||
}[];
|
||||
guest?: {
|
||||
name: string;
|
||||
/** Format: email */
|
||||
email: string;
|
||||
phone?: string;
|
||||
};
|
||||
couponCode?: string;
|
||||
/** @description Names for the places (events that collect them) */
|
||||
attendees?: {
|
||||
eventOptionId: string;
|
||||
variantId?: string | null;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
/** @description YYYY-MM-DD (youth events) */
|
||||
dateOfBirth?: string | null;
|
||||
gender?: string | null;
|
||||
parentName?: string | null;
|
||||
parentEmail?: string | null;
|
||||
parentPhone?: string | null;
|
||||
}[];
|
||||
/**
|
||||
* @description With attendees: tickets to the payer, or to each attendee
|
||||
* @enum {string}
|
||||
*/
|
||||
ticketDelivery?: "payer" | "each";
|
||||
waitlistToken?: string;
|
||||
analyticsSessionId?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
status: "pending" | "confirmed" | "partial_paid" | "paid" | "cancelled";
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Format: date-time */
|
||||
startDate: string;
|
||||
/** Format: date-time */
|
||||
endDate: string;
|
||||
};
|
||||
items: {
|
||||
ticket: string;
|
||||
variant: string | null;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}[];
|
||||
/** @description In rand, after discounts */
|
||||
totalDue: number;
|
||||
/** @description In rand (refunds subtracted) */
|
||||
amountPaid: number;
|
||||
/** @description totalDue − amountPaid; 0 or less = nothing owing */
|
||||
balance: number;
|
||||
/** @description false = added to an existing registration */
|
||||
isNew: boolean;
|
||||
couponError: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getRegistrationsId: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The id */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
status: "pending" | "confirmed" | "partial_paid" | "paid" | "cancelled";
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Format: date-time */
|
||||
startDate: string;
|
||||
/** Format: date-time */
|
||||
endDate: string;
|
||||
};
|
||||
items: {
|
||||
ticket: string;
|
||||
variant: string | null;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}[];
|
||||
/** @description In rand, after discounts */
|
||||
totalDue: number;
|
||||
/** @description In rand (refunds subtracted) */
|
||||
amountPaid: number;
|
||||
/** @description totalDue − amountPaid; 0 or less = nothing owing */
|
||||
balance: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not found (NOT_FOUND) */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
postRegistrationsIdCancel: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The id */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
status: "pending" | "confirmed" | "partial_paid" | "paid" | "cancelled";
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Format: date-time */
|
||||
startDate: string;
|
||||
/** Format: date-time */
|
||||
endDate: string;
|
||||
};
|
||||
items: {
|
||||
ticket: string;
|
||||
variant: string | null;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}[];
|
||||
/** @description In rand, after discounts */
|
||||
totalDue: number;
|
||||
/** @description In rand (refunds subtracted) */
|
||||
amountPaid: number;
|
||||
/** @description totalDue − amountPaid; 0 or less = nothing owing */
|
||||
balance: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not found (NOT_FOUND) */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
postRegistrationsIdCheckout: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The id */
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description An optional donation added on top, in rand */
|
||||
donationAmount?: number;
|
||||
/** @description yoco or payfast (default: the first one switched on) */
|
||||
provider?: string;
|
||||
/** Format: uri */
|
||||
successUrl?: string;
|
||||
/** Format: uri */
|
||||
cancelUrl?: string;
|
||||
/** Format: uri */
|
||||
failureUrl?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
/** @constant */
|
||||
priceUpdated: true;
|
||||
newBalance: number;
|
||||
message: string;
|
||||
} | {
|
||||
/** @constant */
|
||||
priceUpdated: false;
|
||||
checkoutId: string;
|
||||
redirectUrl: string;
|
||||
provider: string;
|
||||
/** @description Charged now, in rand (with any donation) */
|
||||
amount: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not found (NOT_FOUND) */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getMe: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
/** @enum {string} */
|
||||
role: "admin" | "supervisor" | "staff" | "user";
|
||||
twoFactorEnabled: boolean;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getMeRegistrations: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Page number, from 1 */
|
||||
page?: number;
|
||||
/** @description Items per page (at most 100) */
|
||||
limit?: number;
|
||||
/** @description Include past and cancelled registrations */
|
||||
past?: "true" | "false";
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
data: {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
status: "pending" | "confirmed" | "partial_paid" | "paid" | "cancelled";
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
event: {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Format: date-time */
|
||||
startDate: string;
|
||||
/** Format: date-time */
|
||||
endDate: string;
|
||||
};
|
||||
items: {
|
||||
ticket: string;
|
||||
variant: string | null;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}[];
|
||||
/** @description In rand, after discounts */
|
||||
totalDue: number;
|
||||
/** @description In rand (refunds subtracted) */
|
||||
amountPaid: number;
|
||||
/** @description totalDue − amountPaid; 0 or less = nothing owing */
|
||||
balance: number;
|
||||
}[];
|
||||
meta: {
|
||||
page: number;
|
||||
limit: number;
|
||||
/** @description Items across all pages */
|
||||
total: number;
|
||||
/** @description Number of pages (at least 1) */
|
||||
pages: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation failed (VALIDATION_FAILED) */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not signed in (UNAUTHENTICATED) */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
+17
-9
@@ -74,8 +74,9 @@ export function resolveToApiOrigin(urlOrPath: string | null | undefined): string
|
||||
return API_BASE ? `${API_BASE}${raw}` : raw;
|
||||
}
|
||||
|
||||
// The parsed JSON body of a failed request — the backend always sends `message`, and
|
||||
// some routes add a machine-readable `code` or extra detail fields.
|
||||
// The parsed JSON body of a failed request — v1 sends `message` (some routes add a
|
||||
// machine-readable `code` or extra detail fields); for v2 it is the `error` object
|
||||
// (`code`, `message`, and `fields` on validation errors).
|
||||
export type ApiErrorData = { message?: string; code?: string; [key: string]: unknown };
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -122,6 +123,11 @@ export async function apiFetch<T = unknown>(path: string, options: ApiOptions =
|
||||
const json = JSON.parse(text);
|
||||
if (json?.message && typeof json.message === "string") message = json.message;
|
||||
data = json;
|
||||
// /api/v2 errors: { error: { code, message, fields? } }
|
||||
if (json?.error && typeof json.error === "object" && typeof json.error.message === "string") {
|
||||
message = json.error.message;
|
||||
data = { ...json.error };
|
||||
}
|
||||
} catch { /* not JSON — use raw text */ }
|
||||
// An admin/supervisor session from before 2FA was required for their role:
|
||||
// end it and send them to log in, where they enrol.
|
||||
@@ -198,21 +204,23 @@ export function fetchAllPayments(token: string, params: Record<string, string> =
|
||||
return fetchAllPages<Payment>("/api/payments", token, params);
|
||||
}
|
||||
|
||||
// Creates an online checkout (default payment provider) for a registration's full remaining balance.
|
||||
// Omitting `amount` makes the backend charge the full remainder rather than a partial amount —
|
||||
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
|
||||
// Starts an online payment for everything still owed on one of my registrations (API v2), with
|
||||
// an optional donation on top. Partial amounts are only taken from the supervisor payments page.
|
||||
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
|
||||
|
||||
export function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
|
||||
return apiFetch<CheckoutResponse>("/api/payments/checkout", {
|
||||
method: "POST",
|
||||
export async function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
|
||||
const { apiV2 } = await import("@/lib/apiV2");
|
||||
const { data } = await apiV2("post", "/registrations/{id}/checkout", {
|
||||
params: { id: registrationId },
|
||||
authToken: token,
|
||||
body: {
|
||||
registrationId,
|
||||
...(donationAmount && donationAmount > 0 ? { donationAmount } : {}),
|
||||
successUrl: window.location.origin + "/payment/success",
|
||||
cancelUrl: window.location.origin + "/payment/cancel",
|
||||
failureUrl: window.location.origin + "/payment/failure",
|
||||
},
|
||||
});
|
||||
// The price changed since registering: nothing was started — show the new amount owing.
|
||||
if (data.priceUpdated) return { priceUpdated: true, newTotal: data.newBalance, message: data.message };
|
||||
return { redirectUrl: data.redirectUrl };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { paths } from "@/lib/api-v2/schema";
|
||||
|
||||
/**
|
||||
* Typed calls to /api/v2. The types come from the backend's OpenAPI document
|
||||
* (`npm run api:types` in the repo root regenerates src/lib/api-v2/), so a path, its parameters,
|
||||
* its body and its answer are all checked by TypeScript:
|
||||
*
|
||||
* const { data, meta } = await apiV2("get", "/events", { query: { q: "camp", limit: 10 } });
|
||||
* const { data } = await apiV2("post", "/registrations/{id}/cancel", { params: { id }, authToken });
|
||||
*
|
||||
* Errors throw ApiError (from apiFetch) with the v2 `code` and `fields` in `.data`.
|
||||
*/
|
||||
|
||||
type Method = "get" | "post" | "put" | "patch" | "delete";
|
||||
type PathsWith<M extends Method> = { [P in keyof paths]: paths[P][M] extends { responses: unknown } ? P : never }[keyof paths];
|
||||
type Op<P extends keyof paths, M extends Method> = NonNullable<paths[P][M]>;
|
||||
|
||||
type Json<T> = T extends { content: { "application/json": infer J } } ? J : never;
|
||||
type Responses<O> = O extends { responses: infer R } ? R : never;
|
||||
/** The 2xx answer's JSON. */
|
||||
export type V2Result<P extends keyof paths, M extends Method> = Json<Responses<Op<P, M>>[Extract<keyof Responses<Op<P, M>>, 200 | 201>]>;
|
||||
|
||||
type PathParams<O> = O extends { parameters: { path: infer X } } ? (X extends Record<string, unknown> ? X : never) : never;
|
||||
type QueryParams<O> = O extends { parameters: { query?: infer X } } ? X : never;
|
||||
type Body<O> = O extends { requestBody: { content: { "application/json": infer B } } } ? B : never;
|
||||
|
||||
type IfAny<T, Then> = [T] extends [never] ? object : Then;
|
||||
export type V2Options<P extends keyof paths, M extends Method> =
|
||||
{ authToken?: string | null }
|
||||
& IfAny<PathParams<Op<P, M>>, { params: PathParams<Op<P, M>> }>
|
||||
& IfAny<QueryParams<Op<P, M>>, { query?: QueryParams<Op<P, M>> }>
|
||||
& IfAny<Body<Op<P, M>>, { body: Body<Op<P, M>> }>;
|
||||
|
||||
export function apiV2<M extends Method, P extends PathsWith<M>>(method: M, path: P, ...[options]: object extends V2Options<P, M> ? [V2Options<P, M>?] : [V2Options<P, M>]): Promise<V2Result<P, M>> {
|
||||
const opts = (options || {}) as { authToken?: string | null; params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown };
|
||||
let url = String(path).replace(/\{(\w+)\}/g, (_, name: string) => encodeURIComponent(String(opts.params?.[name] ?? "")));
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query || {})) {
|
||||
if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
|
||||
}
|
||||
if (qs.toString()) url += `?${qs.toString()}`;
|
||||
return apiFetch<V2Result<P, M>>(`/api/v2${url}`, { method: method.toUpperCase(), authToken: opts.authToken, body: opts.body });
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.1",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
@@ -16,7 +16,8 @@
|
||||
"demo:small": "npm --prefix backend run set-demo -- --profile=small",
|
||||
"demo:events": "npm --prefix backend run set-demo -- --profile=events",
|
||||
"demo:ministry": "npm --prefix backend run set-demo -- --profile=ministry",
|
||||
"demo:multicampus": "npm --prefix backend run set-demo -- --profile=multicampus"
|
||||
"demo:multicampus": "npm --prefix backend run set-demo -- --profile=multicampus",
|
||||
"api:types": "npm --prefix backend run openapi && npm --prefix frontend run api:types"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
Reference in New Issue
Block a user