Compare commits

...
4 Commits
Author SHA1 Message Date
joshua ce9734090c Merge branch 'feat/api-v2-checkin-accounts' 2026-10-29 09:31:01 +02:00
joshuaandClaude Opus 5.5 9012ec64b6 API v2 accounts and check-in (1.51.2)
- v2: auth/signup, auth/login (+ code step, rate limited), PATCH /me; check-in look-up and
  scan returning an outcome for every normal result. Campus rule as v1.
- Sign up/login/2FA/profile moved to src/services/auth.ts; scan preview and scan to
  src/services/checkin.ts. v1 answers unchanged (ServiceError v1Status).
- Login, 2FA step, sign up and profile/password pages use v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 09:31:00 +02:00
joshua 7c252e0cb3 Merge branch 'feat/api-v2-writes' 2026-10-29 09:09:09 +02:00
joshuaandClaude Opus 5.5 735a76cc74 API v2 registrations and payments (1.51.1)
- v2: register, get, cancel and checkout a registration (owner/admin only).
- Registration create/cancel moved to src/services/registrations.ts and online checkout to
  src/services/payments.ts; v1 controllers call the same services (ServiceError carries status).
- Typed frontend calls: npm run api:types writes openapi.json and schema.d.ts; apiV2() helper.
  Register page, Pay now and My Events cancel use v2; apiFetch reads v2 errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 09:09:09 +02:00
39 changed files with 7097 additions and 1496 deletions
+47
View File
@@ -7,6 +7,53 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.51.2] - 2026-10-29
### Added
- **API v2 accounts:** `POST /auth/signup` (needs `acceptTerms`), `POST /auth/login` (answers
`signed_in` with a token, or `code_required` for two-factor) and `POST /auth/login/code`, both
behind the login rate limit; `PATCH /me` (only what is sent changes; a new password needs the
current one) and `GET /me` now with `notificationPreference` and `homeCampusId`.
- **API v2 check-in:** `GET /checkin/{qrCode}` (look up: holder, what's left, session, consent)
and `POST /checkin/{qrCode}` (check in everyone left or `people`). Every normal result is a 200
with an `outcome` — `checked_in`, `already_used`, `consent_required`, `wrong_event`,
`wrong_section`, `section_not_found`, `no_session`, `locked`, `transferred`. Staff only, with the
same campus rule as v1.
- **Services:** sign up, log in, the 2FA step and profile changes moved from `userController.js`
(~430 lines shorter) to `src/services/auth.ts`; scan preview and scanning from
`ticketController.js` to `src/services/checkin.ts`. v1 answers exactly as before (`ServiceError`
can carry the old `v1Status`, e.g. 400 for a wrong password, which is 401 in v2).
- v2 routes can take extra middleware (`middleware: [loginLimiter]`).
### Changed
- Logging in (and the 2FA code step), signing up, and changing your details or password use API v2.
The scanner stays on v1 until 1.51.3.
## [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
+4 -1
View File
@@ -890,7 +890,10 @@ 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),
signing up, logging in (with the 2FA step), changing my details and checking tickets in (1.51.2);
the frontend's v2 calls are typed from the OpenAPI document.
- **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
+10
View File
@@ -12,6 +12,16 @@ 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.2
- **More groundwork for 2.0.** Logging in, signing up and changing your details now run on the
new version of the server's connection to the app. They work just as before.
## 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
+2 -1
View File
@@ -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:
```
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.51.0",
"version": "1.51.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.51.0",
"version": "1.51.2",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.0",
"version": "1.51.2",
"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",
+22
View File
@@ -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);
}
+7 -48
View File
@@ -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);
}
+15 -581
View File
@@ -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
+18 -273
View File
@@ -1,8 +1,9 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { assertRegistrationEventOpen } = require('../utils/cashupUtils');
// Scanning, shared with /api/v2/checkin (TypeScript, 1.51.2).
const checkin = require('../services/checkin');
// @desc Generate tickets for a registration
// @route POST /api/tickets/generate
@@ -256,99 +257,11 @@ const getTicketByQrCode = async (req, res) => {
// @access Private/Staff
const getScanPreview = async (req, res) => {
try {
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
const rows = await prisma.$queryRaw`
SELECT
t.id,
t."qrCode",
t.quantity,
t."isUsed",
t."eventId",
ro.id AS "registrationOptionId",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
ov.id AS "variantId",
ov.name AS "variantName",
e.id AS "evId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
u.id AS "userId",
u.name AS "userName",
a.name AS "attendeeName",
COALESCE(
json_agg(
json_build_object(
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "User" u ON u.id = t."userId"
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
const r = rows[0];
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
const usages = r.usages || [];
// Multi-day events: check-in is per session, so what's left is counted for the
// session this scan is for (the one picked, or today's).
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
let session = null;
let sessionError = null;
if (r.scheduleType === 'sessions') {
const sessionsUtil = require('../utils/sessions');
const schedule = await sessionsUtil.eventSchedule(r.eventId);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null, new Date(), r.chosenSessionIds);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
remaining = Number(r.quantity) - sessionsUtil.redeemedForSession(usages, found.session.id);
} else {
remaining = 0;
}
}
res.json({
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
consentStatus,
remaining,
session,
sessionError,
registrationOption: {
id: r.registrationOptionId,
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
},
event: { id: r.evId, title: r.eventTitle },
user: { id: r.userId, name: r.userName },
attendee: r.attendeeName ? { name: r.attendeeName } : null,
usages
});
const result = await checkin.previewScan(req.params.qrCode, { sessionId: req.query.sessionId || null });
if (result.outcome === 'transferred') return res.status(410).json({ message: result.message, code: 'TICKET_TRANSFERRED' });
res.json(result.ticket);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
@@ -357,187 +270,19 @@ const getScanPreview = async (req, res) => {
// @access Private/Staff
const scanTicket = async (req, res) => {
try {
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
const rows = await prisma.$queryRaw`
SELECT
t.id,
t.quantity,
t."isUsed",
t."eventId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
COALESCE(
json_agg(
json_build_object(
'id', tu.id,
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, e.id, eo.id, ro.id
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
const r = rows[0];
const ticket = {
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || []
};
// Optional server-side guard: ensure ticket belongs to the requested event when provided
const providedEventId = String((req.query?.eventId || req.body?.eventId) || '').trim();
if (providedEventId && ticket.eventId !== providedEventId) {
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
}
// Section checks used to happen only in the browser; enforce them here too.
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
if (sectionId) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
if (!section || section.eventId !== ticket.eventId) {
return res.status(400).json({ message: 'Section not found for this event', ticket });
}
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
}
}
// A section (or the whole event) locked to another device for offline scanning
// can't be scanned online — those scans would be invisible to the offline device.
const { blockingLock } = require('../utils/scanLocks');
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
if (lock) {
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
}
// Multi-day events: the check-in is for one session (the one picked, or today's), and
// what's left is counted per session.
let session = null;
let sessionsUtil = null;
let schedule = null;
if (r.scheduleType === 'sessions') {
sessionsUtil = require('../utils/sessions');
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null, new Date(), r.chosenSessionIds);
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
session = found.session;
}
// Compute how many have already been redeemed
const totalRedeemed = session
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
: (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (ticket.quantity || 1) - totalRedeemed;
if (remaining <= 0) {
if (session) {
const last = (ticket.usages || []).filter(u => u.sessionId === session.id).sort((a, b) => new Date(b.scannedAt) - new Date(a.scannedAt))[0];
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
return res.status(403).json({
message: `Already checked in for ${sessionsUtil.sessionLabel(session, schedule.sessions)}${at ? ` at ${at}` : ''}`,
ticket,
remaining: 0
});
}
return res.status(403).json({
message: 'Ticket has already been fully used',
ticket,
remaining: 0
});
}
// Youth events: no check-in without parental consent, unless a supervisor overrides.
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
if (!['not_required', 'given'].includes(consentStatus)) {
const canOverride = ['admin', 'supervisor'].includes(req.user?.role);
if (!(req.body?.overrideConsent === true && canOverride)) {
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
return res.status(403).json({ message: `${why} — this ticket can't be checked in.`, code: 'CONSENT_REQUIRED', consentStatus, canOverride, ticket });
}
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'consent_override',
targetType: 'Ticket', targetId: ticket.id,
metadata: { consentStatus, eventId: ticket.eventId },
ip: getClientIp(req),
});
}
// Determine how many to redeem this scan (defaults to all remaining)
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
const fullyUsed = newRemaining <= 0;
// Run the usage insert and ticket status update in parallel — they don't depend on each other
const [ticketUsage] = await Promise.all([
prisma.ticketUsage.create({
data: {
id: uuidv4(),
ticketId: ticket.id,
scannedById: req.user.id,
quantityRedeemed: qtyToRedeem,
sessionId: session ? session.id : null,
}
}),
// A multi-session ticket is never "used up" — it's checked in again next session.
prisma.ticket.update({
where: { id: ticket.id },
data: { isUsed: session ? false : fullyUsed, updatedAt: new Date() }
})
]);
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
// the QR camera flow are unaffected)
if (ticket.registrationOption?.eventOption?.isMainTicket) {
require('../utils/notifications')
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
const sessionPart = session ? ` for ${sessionsUtil.sessionLabel(session, schedule.sessions)}` : '';
res.json({
message: `Ticket scanned successfully${sessionPart} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
ticketUsage,
ticket,
qtyRedeemed: qtyToRedeem,
totalRedeemed: newTotalRedeemed,
remaining: newRemaining,
});
const { getClientIp } = require('../utils/requestUtils');
const result = await checkin.scanTicket(req.params.qrCode, req.user, {
eventId: req.query?.eventId || req.body?.eventId || null,
sectionId: req.query?.sectionId || req.body?.sectionId || null,
sessionId: req.body?.sessionId || req.query?.sessionId || null,
deviceId: req.body?.deviceId || null,
qty: parseInt(req.body?.qty || req.body?.quantity) || null,
overrideConsent: req.body?.overrideConsent === true,
}, getClientIp(req));
const { status, body } = checkin.v1ScanResponse(result);
res.status(status).json(body);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
+18 -452
View File
@@ -3,28 +3,22 @@ const { generateToken, hashPassword, comparePassword } = require('../config/auth
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const { getSettingSync } = require('../utils/settingsCache');
const {
METHODS: TWO_FACTOR_METHODS,
availableMethods,
createChallenge,
verifyChallenge,
resendChallenge,
issueTrustedDevice,
isTrustedDevice,
clearTrustedDevices,
requiresTwoFactor,
enforcementMethod,
} = require('../utils/twoFactor');
const axios = require('axios');
// ─── Helpers ────────────────────────────────────────────────────────────────
// Org name for email subjects — falls back to Cross Code (the platform vendor)
// rather than any specific customer's name when unconfigured.
function getOrgName() {
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
}
// Sign up, log in and profile changes are shared with /api/v2 (TypeScript, 1.51.2).
const accounts = require('../services/auth');
const { getOrgName, sendActivationLink, sendWelcomeEmail } = accounts;
const { v1StatusOf } = require('../services/errors');
// Resolve a client IP from the request (works behind proxies)
function getClientIp(req) {
@@ -33,145 +27,10 @@ function getClientIp(req) {
return req.socket?.remoteAddress || 'unknown';
}
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
// Fire-and-forget: create a 24h activation token and deliver it to an inactive
// account — via email if it has a real (non-guest) address, otherwise via
// WhatsApp if it has a phone number. Used both when a login attempt hits an
// inactive account, and immediately when an admin/supervisor creates an
// account on someone's behalf (walk-in / manual registration).
async function sendActivationLink(user) {
const hasRealEmail = !!(user?.email && !user.email.endsWith('@guest.local'));
if (!hasRealEmail && !user?.phoneNumber) return;
try {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false },
data: { used: true }
});
await prisma.passwordReset.create({
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
if (hasRealEmail) {
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
} else {
const orgName = getOrgName();
const waMessage = [
`🔓 *Activate your ${orgName} account*`,
'',
`Hi ${user.name || 'there'},`,
'',
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
'',
activationUrl,
'',
`_This link expires in 24 hours._`,
].join('\n');
const { waTextAny } = require('../utils/notify');
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
}
} catch (e) {
console.warn('[activation token] Failed to create activation token:', e?.message || e);
}
}
// Fire-and-forget: send a login notification email with approximate geo location
async function sendLoginNotification(user, req) {
try {
const ip = getClientIp(req);
const userAgent = req.headers['user-agent'] || 'Unknown device';
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
let location = 'Unknown location';
if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) {
try {
const geo = await axios.get(
`http://ip-api.com/json/${ip}?fields=status,city,regionName,country`,
{ timeout: 3000 }
);
if (geo.data?.status === 'success') {
const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean);
if (parts.length) location = parts.join(', ');
}
} catch { /* geo lookup failure is non-fatal */ }
}
const { sendMail, buildLoginNotificationEmail } = require('../utils/email');
const { buildWALogin } = require('../utils/waMessages');
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
// Security: always email; also WhatsApp if preferred
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
const { waText } = require('../utils/notify');
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
} catch (e) {
console.warn('[login notification] Failed:', e?.message || e);
}
}
// Fire-and-forget: send welcome email with next upcoming events
async function sendWelcomeEmail(user) {
try {
const events = await prisma.event.findMany({
where: { isActive: true, startDate: { gt: new Date() } },
orderBy: { startDate: 'asc' },
take: 3,
select: { title: true, startDate: true },
});
const { sendMail, buildWelcomeEmail } = require('../utils/email');
const { buildWAWelcome } = require('../utils/waMessages');
const content = buildWelcomeEmail({ name: user.name, events });
const { shouldEmail, waText } = require('../utils/notify');
// Welcome is always sent via email; also via WhatsApp if preferred
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
} catch (e) {
console.warn('[welcome email] Failed:', e?.message || e);
}
}
// Fields the login flow needs once the password (and any 2FA code) has checked out.
const LOGIN_USER_SELECT = {
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
noticeVersionAccepted: true,
};
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
// the controller convention of res.status(...) before throwing.
// Errors from utils/twoFactor and the services carry their own HTTP status; everything else
// follows the controller convention of res.status(...) before throwing.
function errorStatus(error, res) {
return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
}
// Final step of a successful login (after the password, and the 2FA code when
// required): notify, record the activity, and issue the session token.
async function completeLogin(user, req, res, extra = {}) {
const noticePromptPending = await require('../utils/privacyNotice').noticePromptPending(user).catch(() => false);
// Retention: logging in keeps the account (and cancels an inactivity warning).
prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), inactivityWarnedAt: null } }).catch(() => {});
// Send login notification in the background
sendLoginNotification(user, req).catch(() => {});
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
token: generateToken(user.id, user.role, user.tokenVersion),
// POPIA: ask about News & announcements once (see NewsPrompt on the frontend), and to
// accept a privacy notice version marked as a significant change (NoticePrompt).
newsPromptPending: !user.newsAskedAt,
noticePromptPending,
...extra
});
return v1StatusOf(error, res.statusCode === 200 ? 400 : res.statusCode);
}
// @desc Register a new user
@@ -179,79 +38,9 @@ async function completeLogin(user, req, res, extra = {}) {
// @access Public
const registerUser = async (req, res) => {
try {
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = req.body;
// Normalize phone using SA-aware normalisation
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null);
// Validate preference — WhatsApp requires a valid phone number
const allowedPrefs = ['email', 'whatsapp', 'both'];
let pref = allowedPrefs.includes(notificationPreference) ? notificationPreference : 'email';
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) {
pref = 'email'; // silently fall back if no valid number
}
// Check if user already exists
const userExists = await prisma.user.findFirst({
where: {
OR: [
{ email },
...(phone ? [{ phoneNumber: phone }] : [])
]
}
});
if (userExists) {
res.status(400);
throw new Error('User already exists');
}
// Hash password
const hashedPassword = await hashPassword(password);
// Create user
const user = await prisma.user.create({
data: {
id: uuidv4(),
name,
email,
password: hashedPassword,
phoneNumber: phone,
notificationPreference: pref,
updatedAt: new Date()
}
});
if (user) {
// POPIA: the sign-up form's (unticked) "news and announcements" box. Recorded either
// way, so they aren't asked again at their first login.
await require('../utils/privacy').setNewsPreferences(
user.id,
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
{ source: 'signup', ip: getClientIp(req), answered: true, logAll: true },
).catch(e => console.warn('[signup] news preference not saved:', e?.message || e));
// The sign-up form requires ticking "I agree to the Terms of Use and Privacy Policy".
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'signup', ip: getClientIp(req) })
.catch(e => console.warn('[signup] notice acceptance not saved:', e?.message || e));
// Send welcome email in the background — don't block the response
sendWelcomeEmail(user).catch(() => {});
res.status(201).json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
token: generateToken(user.id, user.role, user.tokenVersion)
});
} else {
res.status(400);
throw new Error('Invalid user data');
}
res.status(201).json(await accounts.signUp(req.body || {}, { ip: getClientIp(req), userAgent: req.headers['user-agent'] }));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
@@ -260,108 +49,12 @@ const registerUser = async (req, res) => {
// @access Public
const loginUser = async (req, res) => {
try {
const {email, password, deviceToken} = req.body;
const rawInput = email;
// Normalize phone input — convert SA local format (0xx) → international (27xx)
const { normalizeZAPhone } = require('../utils/whatsapp');
const normalizedPhone = normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
// Check for user email or phone
const user = await prisma.user.findFirst({
where: {
OR: [
{ email: rawInput },
...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : []),
]
}
});
if (!user) {
res.status(401);
throw new Error('User does not exist');
const { email, password, deviceToken } = req.body || {};
const result = await accounts.logIn({ identifier: email, password, deviceToken }, { ip: getClientIp(req), userAgent: req.headers['user-agent'] });
if (result.status === 'code_required') {
return res.json({ twoFactorRequired: true, ...(result.enrolling ? { enrolling: true } : {}), ...result.challenge });
}
// Check if user is active
if (!user.isActive) {
// Resend the activation link on each failed login attempt against an inactive
// account, in case the original one (sent at creation, or a prior attempt) expired.
await sendActivationLink(user);
// If the account has a real email (not a guest placeholder), it went out via email
if (user.email && !user.email.endsWith('@guest.local')) {
res.status(401);
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
}
// No real email — if they have a phone number, it went out via WhatsApp
if (user.phoneNumber) {
res.status(401);
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
}
res.status(401);
throw new Error('Your account has been deactivated');
}
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 5 * 60 * 1000; // 5 min
// Check lock
if (user.lockUntil && user.lockUntil > new Date()) {
const now = new Date().getTime();
const lockTime = new Date(user.lockUntil).getTime();
const diffMs = lockTime - now;
const diffMinutes = Math.ceil(diffMs / (1000 * 60));
throw new Error(`Too many attempts. Try again in ${diffMinutes} minute(s).`);
}
// Check password
const isMatch = await comparePassword(password, user.password);
if (!isMatch) {
const attempts = user.failedAttempts + 1;
await prisma.user.update({
where: { id: user.id },
data: {
failedAttempts: attempts,
lockUntil:
attempts >= MAX_ATTEMPTS
? new Date(Date.now() + LOCK_TIME)
: null,
},
});
throw new Error("Invalid email or password");
}
// ✅ SUCCESS → reset attempts
const updated = await prisma.user.update({
where: { id: user.id },
data: { failedAttempts: 0, lockUntil: null },
select: LOGIN_USER_SELECT,
});
// Two-factor: the password alone isn't enough unless this browser was
// remembered earlier. Send a code and hand back a challenge instead of a token.
if (updated.twoFactorEnabled && !(await isTrustedDevice(updated.id, deviceToken))) {
const challenge = await createChallenge(updated, 'login', updated.twoFactorMethod || 'email');
return res.json({ twoFactorRequired: true, ...challenge });
}
// Admins and supervisors must use 2FA. Not set up yet: send a code to the
// first channel that can deliver one, and verifying it turns 2FA on. A
// trusted device can't skip this — nothing has been verified yet.
if (!updated.twoFactorEnabled && requiresTwoFactor(updated)) {
const method = await enforcementMethod(updated);
if (method) {
const challenge = await createChallenge(updated, 'login', method);
return res.json({ twoFactorRequired: true, enrolling: true, ...challenge });
}
}
await completeLogin(updated, req, res);
res.json(result.session);
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
@@ -373,30 +66,7 @@ const loginUser = async (req, res) => {
const verifyLoginTwoFactor = async (req, res) => {
try {
const { challengeId, code, rememberDevice } = req.body || {};
const challenge = await verifyChallenge(challengeId, code, 'login');
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
if (!user || !user.isActive) {
res.status(401);
throw new Error('Your account has been deactivated');
}
// First login since 2FA became required for this role: the code proves the
// channel works, so turn 2FA on with it.
if (!user.twoFactorEnabled && requiresTwoFactor(user)) {
user = await prisma.user.update({
where: { id: user.id },
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
select: LOGIN_USER_SELECT,
});
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
const extra = {};
if (rememberDevice === true) {
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
}
await completeLogin(user, req, res, extra);
res.json(await accounts.verifyLoginCode({ challengeId, code, rememberDevice }, { ip: getClientIp(req), userAgent: req.headers['user-agent'] }));
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
@@ -565,114 +235,10 @@ const getUserProfile = async (req, res) => {
// @access Private
const updateUserProfile = async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id }
});
if (!user) {
res.status(404);
throw new Error('User not found');
}
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = req.body || {};
// Home campus (only while campuses are on): an active campus, or ''/null for none.
let newHomeCampusId = user.homeCampusId;
if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) {
if (!homeCampusId) newHomeCampusId = null;
else {
const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } });
if (!campus) { res.status(400); throw new Error('Choose one of the campuses in the list.'); }
newHomeCampusId = campus.id;
}
}
// If a password change is requested, verify current password for safety
let newHashedPassword = undefined;
if (typeof password === 'string' && password.trim().length > 0) {
const newPw = password.trim();
if (!currentPassword || typeof currentPassword !== 'string' || currentPassword.length === 0) {
res.status(400);
throw new Error('Current password is required to set a new password');
}
const matches = await comparePassword(currentPassword, user.password);
if (!matches) {
res.status(400);
throw new Error('Current password is incorrect');
}
if (newPw.length < 8) {
res.status(400);
throw new Error('New password must be at least 8 characters long');
}
newHashedPassword = await hashPassword(newPw);
}
// Normalize phone
const { normalizeZAPhone } = require('../utils/whatsapp');
let newPhone = user.phoneNumber;
if (phoneNumber !== undefined) {
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
}
// Validate notification preference — WhatsApp requires a valid SA phone number
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// 2FA codes can't go to WhatsApp without a usable number — move them to email
// rather than leave the account pointing at a channel that no longer exists.
const { isValidZAPhone } = require('../utils/whatsapp');
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
// Update user data
const updatedUser = await prisma.user.update({
where: { id: req.user.id },
data: {
name: name || user.name,
email: email || user.email,
password: newHashedPassword ? newHashedPassword : user.password,
phoneNumber: newPhone,
notificationPreference: newPref,
twoFactorMethod: newTwoFactorMethod,
homeCampusId: newHomeCampusId,
updatedAt: new Date()
},
select: {
id: true,
name: true,
email: true,
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
twoFactorMethod: true,
createdAt: true,
updatedAt: true,
isActive: true,
homeCampusId: true,
tokenVersion: true
}
});
// If the password was changed, send a security alert email (fire-and-forget)
if (newHashedPassword) {
await clearTrustedDevices(updatedUser.id);
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content })
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
const { waText } = require('../utils/notify');
waText(updatedUser, content.text).catch(() => {});
logSecurityEvent({ userId: updatedUser.id, type: 'password_changed', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
res.json({
...updatedUser,
twoFactorRequired: requiresTwoFactor(updatedUser),
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
});
const { user, twoFactorRequired, token } = await accounts.updateProfile(req.user.id, req.body || {}, { ip: getClientIp(req), userAgent: req.headers['user-agent'] });
res.json({ ...user, twoFactorRequired, token });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
+349
View File
@@ -0,0 +1,349 @@
/**
* Accounts: sign up, log in (with the 2FA code step) and changing your own details (1.51.2).
* Moved from userController.js so /api/users/* (v1) and /api/v2/auth/*, /api/v2/me share it.
*/
import { v4 as uuidv4 } from 'uuid';
import axios from 'axios';
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 auth = require('../config/auth') as {
generateToken(id: string, role: string, tokenVersion: number): string;
hashPassword(pw: string): Promise<string>;
comparePassword(pw: string, hash: string): Promise<boolean>;
};
const twoFactor = require('../utils/twoFactor') as {
createChallenge(user: any, purpose: string, method: string): Promise<{ challengeId: string; method: string; destination: string | null }>;
verifyChallenge(challengeId: string, code: string, purpose: string): Promise<{ userId: string; method: string }>;
issueTrustedDevice(userId: string, userAgent?: string): Promise<string>;
isTrustedDevice(userId: string, deviceToken?: string): Promise<boolean>;
clearTrustedDevices(userId: string): Promise<unknown>;
requiresTwoFactor(user: { role: string }): boolean;
enforcementMethod(user: any): Promise<string | null>;
};
const { logSecurityEvent } = require('../utils/securityEvents') as { logSecurityEvent(e: object): Promise<unknown> };
const { getSettingSync } = require('../utils/settingsCache') as { getSettingSync(key: string, fallback: string): string };
const whatsapp = () => require('../utils/whatsapp') as { normalizeZAPhone(p: unknown): string | null; isValidZAPhone(p: unknown): boolean };
/** Who is asking, for security notices and the activity log. */
export type RequestInfo = { ip?: string; userAgent?: string };
// Org name for email subjects — falls back to Cross Code (the platform vendor)
// rather than any specific customer's name when unconfigured.
export function getOrgName(): string {
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
}
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
const frontendUrl = () => (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
/**
* Fire-and-forget: create a 24h activation token and deliver it to an inactive account — by
* email if it has a real (non-guest) address, otherwise by WhatsApp if it has a phone number.
* Used when a login hits an inactive account, and when staff create an account for someone.
*/
export async function sendActivationLink(user: { id: string; name?: string | null; email?: string | null; phoneNumber?: string | null }): Promise<void> {
const hasRealEmail = !!(user?.email && !user.email.endsWith('@guest.local'));
if (!hasRealEmail && !user?.phoneNumber) return;
try {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await prisma.passwordReset.updateMany({ where: { userId: user.id, used: false }, data: { used: true } });
await prisma.passwordReset.create({ data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false } });
const activationUrl = `${frontendUrl()}/activate-account?token=${encodeURIComponent(token)}`;
if (hasRealEmail) {
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
.catch((e: any) => console.warn('[activation email] Failed:', e?.message || e));
} else {
const orgName = getOrgName();
const waMessage = [
`🔓 *Activate your ${orgName} account*`,
'',
`Hi ${user.name || 'there'},`,
'',
'Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:',
'',
activationUrl,
'',
'_This link expires in 24 hours._',
].join('\n');
const { waTextAny } = require('../utils/notify');
waTextAny(user, waMessage).catch((e: any) => console.warn('[activation WA] Failed:', e?.message || e));
}
} catch (e: any) {
console.warn('[activation token] Failed to create activation token:', e?.message || e);
}
}
// Fire-and-forget: a login notice with the approximate place it came from.
async function sendLoginNotification(user: any, info: RequestInfo): Promise<void> {
try {
const ip = info.ip || 'unknown';
const userAgent = info.userAgent || 'Unknown device';
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
let location = 'Unknown location';
if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) {
try {
const geo = await axios.get(`http://ip-api.com/json/${ip}?fields=status,city,regionName,country`, { timeout: 3000 });
if (geo.data?.status === 'success') {
const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean);
if (parts.length) location = parts.join(', ');
}
} catch { /* geo lookup failure is non-fatal */ }
}
const { sendMail, buildLoginNotificationEmail } = require('../utils/email');
const { buildWALogin } = require('../utils/waMessages');
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
// Security: always email; also WhatsApp if preferred
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
const { waText } = require('../utils/notify');
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
} catch (e: any) {
console.warn('[login notification] Failed:', e?.message || e);
}
}
/** Fire-and-forget: the welcome email, with the next few upcoming events. */
export async function sendWelcomeEmail(user: any): Promise<void> {
try {
const events = await prisma.event.findMany({
where: { isActive: true, startDate: { gt: new Date() } },
orderBy: { startDate: 'asc' },
take: 3,
select: { title: true, startDate: true },
});
const { sendMail, buildWelcomeEmail } = require('../utils/email');
const { buildWAWelcome } = require('../utils/waMessages');
const content = buildWelcomeEmail({ name: user.name, events });
const { waText } = require('../utils/notify');
// Welcome is always sent via email; also via WhatsApp if preferred
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
} catch (e: any) {
console.warn('[welcome email] Failed:', e?.message || e);
}
}
// Fields the login flow needs once the password (and any 2FA code) has checked out.
export const LOGIN_USER_SELECT = {
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
noticeVersionAccepted: true,
} as const;
type LoginUser = { id: string; name: string; email: string; role: string; tokenVersion: number; newsAskedAt: Date | null; noticeVersionAccepted: number | null } & Record<string, unknown>;
export type Session = {
id: string; name: string; email: string; role: string; token: string;
newsPromptPending: boolean; noticePromptPending: false | 'first' | 'updated'; deviceToken?: string;
};
/**
* Final step of a successful login (after the password, and the 2FA code when required):
* notify, record the activity, and issue the session token.
*/
export async function completeLogin(user: LoginUser, info: RequestInfo, extra: { deviceToken?: string } = {}): Promise<Session> {
const noticePromptPending = await (require('../utils/privacyNotice') as { noticePromptPending(u: object): Promise<false | 'first' | 'updated'> }).noticePromptPending(user).catch(() => false as const);
// Retention: logging in keeps the account (and cancels an inactivity warning).
prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), inactivityWarnedAt: null } }).catch(() => {});
sendLoginNotification(user, info).catch(() => {});
logSecurityEvent({ userId: user.id, type: 'login', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
token: auth.generateToken(user.id, user.role, user.tokenVersion),
// POPIA: ask about News & announcements once (NewsPrompt on the frontend), and to accept a
// privacy notice version marked as a significant change (NoticePrompt).
newsPromptPending: !user.newsAskedAt,
noticePromptPending,
...extra,
};
}
export type SignUpInput = { name: string; email: string; password: string; phoneNumber?: string | null; notificationPreference?: string; newsOptIn?: boolean };
/** A new account (signed in straight away), with the news choice and privacy notice recorded. */
export async function signUp(input: SignUpInput, info: RequestInfo) {
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = input;
const { normalizeZAPhone, isValidZAPhone } = whatsapp();
const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null);
// WhatsApp needs a valid phone number; otherwise quietly use email
const allowedPrefs = ['email', 'whatsapp', 'both'];
let pref = allowedPrefs.includes(String(notificationPreference)) ? String(notificationPreference) : 'email';
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) pref = 'email';
const userExists = await prisma.user.findFirst({ where: { OR: [{ email }, ...(phone ? [{ phoneNumber: phone }] : [])] } });
if (userExists) fail(409, 'User already exists', undefined, { v1Status: 400 });
const user = await prisma.user.create({
data: { id: uuidv4(), name, email, password: await auth.hashPassword(password), phoneNumber: phone, notificationPreference: pref as 'email' | 'whatsapp' | 'both', updatedAt: new Date() },
});
// POPIA: the sign-up form's (unticked) "news and announcements" box. Recorded either way,
// so they aren't asked again at their first login.
await require('../utils/privacy').setNewsPreferences(
user.id,
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
{ source: 'signup', ip: info.ip, answered: true, logAll: true },
).catch((e: any) => console.warn('[signup] news preference not saved:', e?.message || e));
// The sign-up form requires ticking "I agree to the Terms of Use and Privacy Policy".
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'signup', ip: info.ip })
.catch((e: any) => console.warn('[signup] notice acceptance not saved:', e?.message || e));
sendWelcomeEmail(user).catch(() => {});
return { id: user.id, name: user.name, email: user.email, role: user.role, token: auth.generateToken(user.id, user.role, user.tokenVersion) };
}
export type LoginResult =
| { status: 'signed_in'; session: Session }
| { status: 'code_required'; challenge: { challengeId: string; method: string; destination: string | null }; enrolling: boolean };
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 5 * 60 * 1000;
/**
* Checks an email (or phone number) and password. Five wrong passwords lock the account for
* five minutes. With 2FA (or when the role needs it), a code is sent instead of a token.
*/
export async function logIn(input: { identifier: string; password: string; deviceToken?: string }, info: RequestInfo): Promise<LoginResult> {
const rawInput = String(input.identifier || '');
// SA local format (0xx) → international (27xx)
const normalizedPhone = whatsapp().normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
const user = await prisma.user.findFirst({ where: { OR: [{ email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : [])] } });
if (!user) return fail(401, 'User does not exist');
if (!user.isActive) {
// Resend the activation link on each attempt, in case the earlier one expired.
await sendActivationLink(user);
if (user.email && !user.email.endsWith('@guest.local')) fail(401, 'Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
if (user.phoneNumber) fail(401, 'Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
fail(401, 'Your account has been deactivated');
}
if (user.lockUntil && user.lockUntil > new Date()) {
const minutes = Math.ceil((new Date(user.lockUntil).getTime() - Date.now()) / 60000);
fail(429, `Too many attempts. Try again in ${minutes} minute(s).`, undefined, { v1Status: 400 });
}
if (!(await auth.comparePassword(input.password, user.password))) {
const attempts = user.failedAttempts + 1;
await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: attempts, lockUntil: attempts >= MAX_ATTEMPTS ? new Date(Date.now() + LOCK_TIME) : null } });
fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
}
const updated = await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: 0, lockUntil: null }, select: LOGIN_USER_SELECT });
// Two-factor: the password alone isn't enough unless this browser was remembered earlier.
if (updated.twoFactorEnabled && !(await twoFactor.isTrustedDevice(updated.id, input.deviceToken))) {
return { status: 'code_required', challenge: await twoFactor.createChallenge(updated, 'login', updated.twoFactorMethod || 'email'), enrolling: false };
}
// Admins and supervisors must use 2FA. Not set up yet: send a code to the first channel that
// can take one; verifying it turns 2FA on. A trusted device can't skip this.
if (!updated.twoFactorEnabled && twoFactor.requiresTwoFactor(updated)) {
const method = await twoFactor.enforcementMethod(updated);
if (method) return { status: 'code_required', challenge: await twoFactor.createChallenge(updated, 'login', method), enrolling: true };
}
return { status: 'signed_in', session: await completeLogin(updated, info) };
}
/** The second login step: the emailed or WhatsApp code for a token (and optionally remember this device). */
export async function verifyLoginCode(input: { challengeId: string; code: string; rememberDevice?: boolean }, info: RequestInfo): Promise<Session> {
const challenge = await twoFactor.verifyChallenge(input.challengeId, input.code, 'login');
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
if (!user || !user.isActive) return fail(401, 'Your account has been deactivated');
// First login since 2FA became required for this role: the code proves the channel works,
// so turn 2FA on with it.
if (!user.twoFactorEnabled && twoFactor.requiresTwoFactor(user)) {
user = await prisma.user.update({ where: { id: user.id }, data: { twoFactorEnabled: true, twoFactorMethod: challenge.method as 'email' | 'whatsapp', updatedAt: new Date() }, select: LOGIN_USER_SELECT });
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
}
const extra = input.rememberDevice === true ? { deviceToken: await twoFactor.issueTrustedDevice(user.id, info.userAgent) } : {};
return completeLogin(user, info, extra);
}
export type ProfileInput = {
name?: string; email?: string; phoneNumber?: string | null; notificationPreference?: string; homeCampusId?: string | null;
/** A new password; needs currentPassword. */
password?: string; currentPassword?: string;
};
export const PROFILE_SELECT = {
id: true, name: true, email: true, role: true, phoneNumber: true, notificationPreference: true, twoFactorEnabled: true,
twoFactorMethod: true, createdAt: true, updatedAt: true, isActive: true, homeCampusId: true, tokenVersion: true,
} as const;
/**
* Changes your own details. A new password needs the current one, signs out other devices'
* remembered 2FA, and sends a security notice. Returns the account and a fresh token.
*/
export async function updateProfile(userId: string, input: ProfileInput, info: RequestInfo) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return fail(404, 'User not found');
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = input;
// Home campus (only while campuses are on): an active campus, or ''/null for none.
let newHomeCampusId = user.homeCampusId;
if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) {
if (!homeCampusId) newHomeCampusId = null;
else {
const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } });
if (!campus) fail(400, 'Choose one of the campuses in the list.');
newHomeCampusId = campus!.id;
}
}
// A password change needs the current password
let newHashedPassword: string | undefined;
if (typeof password === 'string' && password.trim().length > 0) {
const newPw = password.trim();
if (!currentPassword) fail(400, 'Current password is required to set a new password');
if (!(await auth.comparePassword(currentPassword!, user.password))) fail(400, 'Current password is incorrect');
if (newPw.length < 8) fail(400, 'New password must be at least 8 characters long');
newHashedPassword = await auth.hashPassword(newPw);
}
const { normalizeZAPhone, isValidZAPhone } = whatsapp();
let newPhone = user.phoneNumber;
if (phoneNumber !== undefined) newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
// WhatsApp needs a valid SA phone number
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// 2FA codes can't go to WhatsApp without a usable number — move them to email.
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
const updated = await prisma.user.update({
where: { id: userId },
data: {
name: name || user.name, email: email || user.email, password: newHashedPassword ?? user.password, phoneNumber: newPhone,
notificationPreference: newPref, twoFactorMethod: newTwoFactorMethod, homeCampusId: newHomeCampusId, updatedAt: new Date(),
},
select: PROFILE_SELECT,
});
if (newHashedPassword) {
await twoFactor.clearTrustedDevices(updated.id);
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updated.name, when: Date.now(), supportEmail });
sendMail({ to: updated.email, subject: `Your ${getOrgName()} password was changed`, ...content })
.catch((e: any) => console.warn('[email] Failed to send password changed alert:', e?.message || e));
const { waText } = require('../utils/notify');
waText(updated, content.text).catch(() => {});
logSecurityEvent({ userId: updated.id, type: 'password_changed', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
}
return { user: updated, twoFactorRequired: twoFactor.requiresTwoFactor(updated), token: auth.generateToken(updated.id, updated.role, updated.tokenVersion) };
}
export { ServiceError };
+286
View File
@@ -0,0 +1,286 @@
/**
* Checking a ticket in by its QR code (1.51.2). Moved from ticketController.js so
* /api/tickets/scan-preview|scan (v1) and /api/v2/checkin/{qrCode} share it.
*
* Scanning has several normal answers besides "checked in" (already used, wrong event, consent
* missing…). They come back as an `outcome`, not thrown, so each caller can show them its way;
* only an unknown code is an error (404).
*/
import { v4 as uuidv4 } from 'uuid';
import { prisma } from '../v2/legacy';
import { fail } from './errors';
import type { Role } from './registrations';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const transfer = () => require('../utils/ticketTransfer') as { findTransferByOldCode(code: string): Promise<any>; transferredMessage(t: any): string };
const consent = () => require('../utils/consent') as { consentStatusForTickets(ids: string[]): Promise<Map<string, string>> };
type Session = { id: string; label?: string | null; startsAt: Date };
const sessions = () => require('../utils/sessions') as {
TIMEZONE: string;
eventSchedule(eventId: string): Promise<{ sessions: Session[] } & Record<string, unknown>>;
ticketScanSession(schedule: any, list: Session[], pinned: string | null, picked: string | null, now: Date, chosen: string[] | null): { session: Session | null; error: string | null };
sessionLabel(session: Session, list: Session[]): string;
redeemedForSession(usages: Usage[], sessionId: string): number;
};
type Usage = { id?: string; quantityRedeemed: number | null; scannedAt: string; sessionId: string | null };
export type ConsentStatus = 'not_required' | 'given' | 'pending' | 'declined' | 'missing' | string;
/** A ticket that was handed on: the old code tells the scanner who has it now. */
async function transferredOutcome(qrCode: string) {
const t = transfer();
const found = await t.findTransferByOldCode(qrCode);
return found ? { outcome: 'transferred' as const, message: t.transferredMessage(found) } : null;
}
export type PreviewResult =
| { outcome: 'transferred'; message: string }
| {
outcome: 'found';
ticket: {
id: string; quantity: number; isUsed: boolean; eventId: string; consentStatus: ConsentStatus; remaining: number;
session: { id: string; label: string } | null; sessionError: string | null;
registrationOption: { id: string | null; eventOption: { id: string | null; name: string | null }; variant: { id: string; name: string } | null };
event: { id: string; title: string }; user: { id: string; name: string }; attendee: { name: string } | null;
usages: Usage[];
};
};
/** What a scan would do, without checking anything in (the scanner's preview card). */
export async function previewScan(qrCode: string, opts: { sessionId?: string | null } = {}): Promise<PreviewResult> {
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
const rows = await prisma.$queryRaw<any[]>`
SELECT
t.id, t."qrCode", t.quantity, t."isUsed", t."eventId",
ro.id AS "registrationOptionId", ro."sessionId" AS "pinnedSessionId", ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId", eo.name AS "eventOptionName",
ov.id AS "variantId", ov.name AS "variantName",
e.id AS "evId", e.title AS "eventTitle", e."scheduleType" AS "scheduleType",
u.id AS "userId", u.name AS "userName",
a.name AS "attendeeName",
COALESCE(
json_agg(json_build_object('quantityRedeemed', tu."quantityRedeemed", 'scannedAt', tu."scannedAt", 'sessionId', tu."sessionId"))
FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "User" u ON u.id = t."userId"
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${qrCode}
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
`;
if (!rows || rows.length === 0) return (await transferredOutcome(qrCode)) ?? fail(404, 'Ticket not found');
const r = rows[0];
const consentStatus = (await consent().consentStatusForTickets([r.id])).get(r.id) || 'not_required';
const usages: Usage[] = r.usages || [];
// Multi-day events: check-in is per session, so what's left is counted for the
// session this scan is for (the one picked, or today's).
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
let session: { id: string; label: string } | null = null;
let sessionError: string | null = null;
if (r.scheduleType === 'sessions') {
const s = sessions();
const schedule = await s.eventSchedule(r.eventId);
const found = s.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, opts.sessionId || null, new Date(), r.chosenSessionIds);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: s.sessionLabel(found.session, schedule.sessions) };
remaining = Number(r.quantity) - s.redeemedForSession(usages, found.session.id);
} else {
remaining = 0;
}
}
return {
outcome: 'found',
ticket: {
id: r.id, quantity: Number(r.quantity), isUsed: r.isUsed, eventId: r.eventId, consentStatus, remaining, session, sessionError,
registrationOption: {
id: r.registrationOptionId,
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
},
event: { id: r.evId, title: r.eventTitle },
user: { id: r.userId, name: r.userName },
attendee: r.attendeeName ? { name: r.attendeeName } : null,
usages,
},
};
}
export type ScanTicket = {
id: string; quantity: number; isUsed: boolean; eventId: string; event: { title: string };
registrationOption: { eventOption: { id: string | null; name: string | null; isMainTicket: boolean | null } };
usages: Usage[];
};
export type ScanOptions = {
/** Refuse a ticket for any other event (the scanner's chosen event). */
eventId?: string | null;
/** Only tickets valid for this section. */
sectionId?: string | null;
/** Multi-day events: the session (default: today's). */
sessionId?: string | null;
/** The scanning device, so its own offline lock doesn't block it. */
deviceId?: string | null;
/** How many people to check in (default: everyone left). */
qty?: number | null;
/** Supervisors and admins: check in without parental consent (logged). */
overrideConsent?: boolean;
};
export type ScanResult =
| { outcome: 'checked_in'; message: string; session: { id: string; label: string } | null; ticketUsage: { id: string; scannedAt: Date; quantityRedeemed: number; sessionId: string | null }; ticket: ScanTicket; qtyRedeemed: number; totalRedeemed: number; remaining: number }
| { outcome: 'transferred'; message: string }
| { outcome: 'wrong_event' | 'section_not_found' | 'wrong_section' | 'no_session'; message: string; ticket: ScanTicket }
| { outcome: 'locked'; message: string }
| { outcome: 'already_used'; message: string; ticket: ScanTicket }
| { outcome: 'consent_required'; message: string; consentStatus: ConsentStatus; canOverride: boolean; ticket: ScanTicket };
/** Checks a ticket in (all of it, or `qty` people), or says why it can't be. */
export async function scanTicket(qrCode: string, actor: { id: string; role: Role }, opts: ScanOptions = {}, ip?: string): Promise<ScanResult> {
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
const rows = await prisma.$queryRaw<any[]>`
SELECT
t.id, t.quantity, t."isUsed", t."eventId",
e.title AS "eventTitle", e."scheduleType" AS "scheduleType",
ro."sessionId" AS "pinnedSessionId", ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId", eo.name AS "eventOptionName", eo."isMainTicket" AS "isMainTicket",
COALESCE(
json_agg(json_build_object('id', tu.id, 'quantityRedeemed', tu."quantityRedeemed", 'scannedAt', tu."scannedAt", 'sessionId', tu."sessionId"))
FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${qrCode}
GROUP BY t.id, e.id, eo.id, ro.id
`;
if (!rows || rows.length === 0) return (await transferredOutcome(qrCode)) ?? fail(404, 'Ticket not found');
const r = rows[0];
const ticket: ScanTicket = {
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || [],
};
// Ensure the ticket belongs to the event being scanned, when given
const providedEventId = String(opts.eventId || '').trim();
if (providedEventId && ticket.eventId !== providedEventId) return { outcome: 'wrong_event', message: 'Ticket belongs to a different event', ticket };
// Section checks used to happen only in the browser; enforce them here too.
const sectionId = String(opts.sectionId || '').trim() || null;
if (sectionId) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
if (!section || section.eventId !== ticket.eventId) return { outcome: 'section_not_found', message: 'Section not found for this event', ticket };
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) return { outcome: 'wrong_section', message: 'This ticket is not valid for this section', ticket };
}
// A section (or the whole event) locked to another device for offline scanning
// can't be scanned online — those scans would be invisible to the offline device.
const { blockingLock } = require('../utils/scanLocks') as { blockingLock(target: object, opts: object): Promise<any> };
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: opts.deviceId || null });
if (lock) {
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
return { outcome: 'locked', message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.` };
}
// Multi-day events: the check-in is for one session (the one picked, or today's), and
// what's left is counted per session.
let session: Session | null = null;
let schedule: { sessions: Session[] } | null = null;
const s = sessions();
if (r.scheduleType === 'sessions') {
schedule = await s.eventSchedule(ticket.eventId);
const found = s.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(opts.sessionId || '').trim() || null, new Date(), r.chosenSessionIds);
if (!found.session) return { outcome: 'no_session', message: found.error || 'No session is open for check-in now', ticket };
session = found.session;
}
const label = session && schedule ? s.sessionLabel(session, schedule.sessions) : '';
const totalRedeemed = session
? s.redeemedForSession(ticket.usages, session.id)
: ticket.usages.reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0);
const remaining = (ticket.quantity || 1) - totalRedeemed;
if (remaining <= 0) {
if (session) {
const last = ticket.usages.filter(u => u.sessionId === session!.id).sort((a, b) => +new Date(b.scannedAt) - +new Date(a.scannedAt))[0];
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: s.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
return { outcome: 'already_used', message: `Already checked in for ${label}${at ? ` at ${at}` : ''}`, ticket };
}
return { outcome: 'already_used', message: 'Ticket has already been fully used', ticket };
}
// Youth events: no check-in without parental consent, unless a supervisor overrides.
const consentStatus = (await consent().consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
if (!['not_required', 'given'].includes(consentStatus)) {
const canOverride = ['admin', 'supervisor'].includes(actor.role);
if (!(opts.overrideConsent === true && canOverride)) {
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
return { outcome: 'consent_required', message: `${why} — this ticket can't be checked in.`, consentStatus, canOverride, ticket };
}
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'consent_override', targetType: 'Ticket', targetId: ticket.id, metadata: { consentStatus, eventId: ticket.eventId }, ip });
}
// How many to check in this scan (defaults to all remaining)
const requestedQty = Math.trunc(Number(opts.qty)) || remaining;
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
// The usage insert and the ticket's status don't depend on each other
const [ticketUsage] = await Promise.all([
prisma.ticketUsage.create({
data: { id: uuidv4(), ticketId: ticket.id, scannedById: actor.id, quantityRedeemed: qtyToRedeem, sessionId: session ? session.id : null },
}),
// A multi-session ticket is never "used up" — it's checked in again next session.
prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: session ? false : newRemaining <= 0, updatedAt: new Date() } }),
]);
// Only Main Tickets trigger a check-in notification
if (ticket.registrationOption.eventOption.isMainTicket) {
(require('../utils/notifications') as { sendCheckInEmails(id: string, qty: number, total: number, left: number): Promise<unknown> })
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
return {
outcome: 'checked_in',
message: `Ticket scanned successfully${session ? ` for ${label}` : ''} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
session: session ? { id: session.id, label } : null,
ticketUsage,
ticket,
qtyRedeemed: qtyToRedeem,
totalRedeemed: newTotalRedeemed,
remaining: newRemaining,
};
}
/** The HTTP status and body v1 (/api/tickets/scan/:qrCode) has always answered for each outcome. */
export function v1ScanResponse(result: ScanResult): { status: number; body: Record<string, unknown> } {
const { outcome, ...rest } = result;
switch (outcome) {
case 'checked_in': return { status: 200, body: rest };
case 'transferred': return { status: 410, body: { ...rest, code: 'TICKET_TRANSFERRED' } };
case 'locked': return { status: 423, body: { ...rest, code: 'SCAN_LOCKED' } };
case 'no_session': return { status: 400, body: { ...rest, code: 'NO_SESSION' } };
case 'already_used': return { status: 403, body: { ...rest, remaining: 0 } };
case 'consent_required': return { status: 403, body: { ...rest, code: 'CONSENT_REQUIRED' } };
default: return { status: 400, body: rest };
}
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 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 —
* `v1Status` when v1 used a different status for it (e.g. 400 for a wrong password).
*/
export class ServiceError extends Error {
readonly statusCode: number;
/** Extra, e.g. a code the caller can switch on. */
readonly code?: string;
readonly v1Status?: number;
constructor(statusCode: number, message: string, code?: string, opts: { v1Status?: number } = {}) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.v1Status = opts.v1Status;
}
}
export const fail = (statusCode: number, message: string, code?: string, opts?: { v1Status?: number }): never => {
throw new ServiceError(statusCode, message, code, opts);
};
/** The status a v1 controller answers for an error (its old one where it differs). */
export const v1StatusOf = (error: { v1Status?: number; statusCode?: number } | null | undefined, fallback: number): number =>
error?.v1Status || error?.statusCode || fallback;
+88
View File
@@ -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 });
}
+497
View File
@@ -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;
}
+3
View File
@@ -7,6 +7,9 @@ import auth from '../middleware/authMiddleware';
// Resources: importing one registers its routes.
import './resources/events';
import './resources/me';
import './resources/registrations';
import './resources/checkin';
import './resources/auth';
const pkg = require('../../package.json') as { version: string };
+105
View File
@@ -0,0 +1,105 @@
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute } from '../route';
import { single } from '../paging';
import { id } from '../schemas';
import auth from '../../middleware/authMiddleware';
import { signUp, logIn, verifyLoginCode, type Session } from '../../services/auth';
const requestInfo = (req: Request) => {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
return { ip: getClientIp(req), userAgent: req.headers['user-agent'] };
};
const session = z.object({
status: z.literal('signed_in'),
token: z.string().describe('Send as `Authorization: Bearer <token>`'),
user: z.object({ id, name: z.string(), email: z.string(), role: z.enum(['admin', 'supervisor', 'staff', 'user']) }),
newsPromptPending: z.boolean().describe('Ask once whether they want News & announcements'),
noticePromptPending: z.union([z.literal(false), z.enum(['first', 'updated'])]).describe('A privacy notice they must accept: never accepted (first) or a newer version (updated)'),
deviceToken: z.string().optional().describe('With rememberDevice: send it with the next login to skip the code on this device'),
});
const toSession = (s: Session): z.input<typeof session> => ({
status: 'signed_in',
token: s.token,
user: { id: s.id, name: s.name, email: s.email, role: s.role as 'admin' | 'supervisor' | 'staff' | 'user' },
newsPromptPending: s.newsPromptPending,
noticePromptPending: s.noticePromptPending,
...(s.deviceToken ? { deviceToken: s.deviceToken } : {}),
});
export const signup = defineRoute({
method: 'post',
path: '/auth/signup',
tag: 'Accounts',
summary: 'Create an account',
description: 'Signs the new account in straight away. `acceptTerms` must be true (the Terms of Use and Privacy Policy); `newsOptIn` is the unticked "News & announcements" box.',
access: 'public',
status: 201,
body: z.object({
name: z.string().trim().min(1).max(200),
email: z.email().max(200),
password: z.string().min(8).max(200),
phone: z.string().trim().max(40).optional(),
notificationPreference: z.enum(['email', 'whatsapp', 'both']).optional().describe('WhatsApp needs a valid South African number; otherwise email is used'),
newsOptIn: z.boolean().optional(),
acceptTerms: z.literal(true),
}),
response: single(session),
async handler({ body, req }) {
const made = await signUp({ name: body.name, email: body.email, password: body.password, phoneNumber: body.phone, notificationPreference: body.notificationPreference, newsOptIn: body.newsOptIn }, requestInfo(req));
return { data: { status: 'signed_in' as const, token: made.token, user: { id: made.id, name: made.name, email: made.email, role: made.role }, newsPromptPending: false, noticePromptPending: false as const } };
},
});
export const login = defineRoute({
method: 'post',
path: '/auth/login',
tag: 'Accounts',
summary: 'Log in',
description: [
'With an email address or phone number. Five wrong passwords lock the account for five minutes (`RATE_LIMITED`).',
'With two-factor authentication (always for supervisors and admins), the answer is `code_required`: a code was sent by email or WhatsApp — finish with `POST /auth/login/code`.',
].join('\n\n'),
access: 'public',
middleware: [auth.loginLimiter],
body: z.object({
identifier: z.string().trim().min(1).max(200).describe('Email address or phone number'),
password: z.string().min(1).max(200),
deviceToken: z.string().max(500).optional().describe('From an earlier login with rememberDevice'),
}),
response: single(z.discriminatedUnion('status', [
session,
z.object({
status: z.literal('code_required'),
challengeId: z.string(),
method: z.enum(['email', 'whatsapp']),
destination: z.string().nullable().describe('Where the code went, partly hidden'),
enrolling: z.boolean().describe('true = this turns two-factor on (required for the role)'),
}),
])),
async handler({ body, req }) {
const result = await logIn(body, requestInfo(req));
if (result.status === 'signed_in') return { data: toSession(result.session) };
return { data: { status: 'code_required' as const, challengeId: result.challenge.challengeId, method: result.challenge.method as 'email' | 'whatsapp', destination: result.challenge.destination ?? null, enrolling: result.enrolling } };
},
});
export const loginCode = defineRoute({
method: 'post',
path: '/auth/login/code',
tag: 'Accounts',
summary: 'Finish logging in with the code',
access: 'public',
middleware: [auth.loginLimiter],
body: z.object({
challengeId: z.string().min(1).max(100),
code: z.string().trim().min(4).max(12),
rememberDevice: z.boolean().optional().describe('Skip the code on this device next time (30 days)'),
}),
response: single(session),
async handler({ body, req }) {
return { data: toSession(await verifyLoginCode(body, requestInfo(req))) };
},
});
+130
View File
@@ -0,0 +1,130 @@
import { z } from 'zod';
import type { Request } from 'express';
import { defineRoute } from '../route';
import { ApiError } from '../errors';
import { single } from '../paging';
import { id } from '../schemas';
import { previewScan, scanTicket, type ScanTicket } from '../../services/checkin';
const OUTCOMES = ['checked_in', 'already_used', 'consent_required', 'wrong_event', 'wrong_section', 'section_not_found', 'no_session', 'locked', 'transferred'] as const;
const qrParams = z.object({ qrCode: z.string().min(1).max(200).describe('The code in the ticket\'s QR') });
/**
* Campus-scoped staff may only check in tickets for their campuses' events (and church-wide
* ones) — the same rule as v1's scopeEvent({ resolve: 'ticketQr', nameCampus: true }).
*/
async function assertTicketCampus(req: Request, qrCode: string) {
const cs = require('../../utils/campusScope') as {
scopeFor(req: unknown): Promise<object | null>;
eventOf(resolve: string, value: string): Promise<{ campusId: string | null; campusName?: string | null } | null>;
canView(scope: object, campusId: string | null): boolean;
};
const scope = await cs.scopeFor(req);
if (!scope) return;
const ev = await cs.eventOf('ticketQr', qrCode);
if (ev && !cs.canView(scope, ev.campusId)) throw new ApiError('FORBIDDEN', `This ticket is for an event at ${ev.campusName || 'another campus'}.`);
}
const ticketInfo = z.object({
id,
eventId: id,
eventTitle: z.string().nullable(),
ticketName: z.string().nullable().describe('The ticket option, e.g. "Adult"'),
quantity: z.number().int().describe('People on this ticket'),
});
const toTicketInfo = (t: ScanTicket) => ({ id: t.id, eventId: t.eventId, eventTitle: t.event.title ?? null, ticketName: t.registrationOption.eventOption.name ?? null, quantity: t.quantity });
const sessionRef = z.object({ id, label: z.string() });
export const preview = defineRoute({
method: 'get',
path: '/checkin/:qrCode',
tag: 'Check-in',
summary: 'Look a ticket up before checking it in',
description: 'Who it is for, how many are left to check in (for today\'s session on multi-day events, or `sessionId`), and parental consent. Nothing is changed.',
access: 'staff',
campusScope: 'event:ticketQr:view (assertTicketCampus)',
params: qrParams,
query: z.object({ sessionId: id.optional().describe('Multi-day events: the session (default: today\'s)') }),
response: single(z.object({
outcome: z.enum(['found', 'transferred']),
message: z.string().nullable().describe('For a transferred ticket: who has it now'),
ticket: z.object({
id,
eventId: id,
eventTitle: z.string().nullable(),
ticketName: z.string().nullable(),
variant: z.string().nullable(),
holder: z.string().nullable().describe('The attendee named on it, or the person who registered'),
quantity: z.number().int(),
remaining: z.number().int().describe('Still to check in (this session, on multi-day events)'),
session: sessionRef.nullable(),
sessionError: z.string().nullable().describe('Why no session is open for check-in now'),
consentStatus: z.string().describe('not_required, given, pending, declined or missing'),
}).nullable(),
})),
async handler({ params, query, req }) {
await assertTicketCampus(req, params.qrCode);
const result = await previewScan(params.qrCode, { sessionId: query.sessionId ?? null });
if (result.outcome === 'transferred') return { data: { outcome: 'transferred' as const, message: result.message, ticket: null } };
const t = result.ticket;
return {
data: {
outcome: 'found' as const,
message: null,
ticket: {
id: t.id, eventId: t.eventId, eventTitle: t.event.title ?? null, ticketName: t.registrationOption.eventOption.name ?? null,
variant: t.registrationOption.variant?.name ?? null, holder: t.attendee?.name ?? t.user.name ?? null, quantity: t.quantity,
remaining: t.remaining, session: t.session, sessionError: t.sessionError, consentStatus: t.consentStatus,
},
},
};
},
});
export const scan = defineRoute({
method: 'post',
path: '/checkin/:qrCode',
tag: 'Check-in',
summary: 'Check a ticket in',
description: [
'Checks in everyone left on the ticket, or `people`. Every normal result is a 200 with an `outcome`:',
'`checked_in`, `already_used`, `consent_required` (a supervisor can resend with `overrideConsent`, which is logged), `wrong_event`, `wrong_section`, `section_not_found`, `no_session` (multi-day: nothing open now), `locked` (offline-locked to another device) or `transferred` (handed on; `message` says to whom).',
'An unknown code is `NOT_FOUND`.',
].join('\n\n'),
access: 'staff',
campusScope: 'event:ticketQr:view (assertTicketCampus)',
params: qrParams,
body: z.object({
eventId: id.optional().describe('Refuse tickets for any other event'),
sectionId: id.optional().describe('Only tickets valid for this section'),
sessionId: id.optional().describe('Multi-day events: the session (default: today\'s)'),
people: z.number().int().min(1).max(10000).optional().describe('How many to check in (default: everyone left)'),
overrideConsent: z.boolean().optional(),
deviceId: z.string().max(200).optional().describe('This scanner, so its own offline lock doesn\'t block it'),
}),
response: single(z.object({
outcome: z.enum(OUTCOMES),
message: z.string(),
ticket: ticketInfo.nullable(),
checkedIn: z.number().int().describe('People checked in by this request (0 unless checked_in)'),
remaining: z.number().int().nullable().describe('Still to check in afterwards'),
session: sessionRef.nullable(),
consentStatus: z.string().nullable(),
canOverride: z.boolean().describe('consent_required: whether you may override'),
})),
async handler({ params, body, user, req }) {
await assertTicketCampus(req, params.qrCode);
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
const r = await scanTicket(params.qrCode, user!, {
eventId: body.eventId, sectionId: body.sectionId, sessionId: body.sessionId, deviceId: body.deviceId,
qty: body.people ?? null, overrideConsent: body.overrideConsent,
}, getClientIp(req));
const base = { outcome: r.outcome, message: r.message, ticket: 'ticket' in r ? toTicketInfo(r.ticket) : null, checkedIn: 0, remaining: null as number | null, session: null as { id: string; label: string } | null, consentStatus: null as string | null, canOverride: false };
if (r.outcome === 'checked_in') return { data: { ...base, checkedIn: r.qtyRedeemed, remaining: r.remaining, session: r.session } };
if (r.outcome === 'already_used') return { data: { ...base, remaining: 0 } };
if (r.outcome === 'consent_required') return { data: { ...base, consentStatus: r.consentStatus, canOverride: r.canOverride } };
return { data: base };
},
});
+39 -47
View File
@@ -2,8 +2,10 @@ 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';
import { updateProfile } from '../../services/auth';
const me = z.object({
id,
@@ -12,9 +14,17 @@ const me = z.object({
phone: z.string().nullable(),
role: z.enum(['admin', 'supervisor', 'staff', 'user']),
twoFactorEnabled: z.boolean(),
notificationPreference: z.enum(['email', 'whatsapp', 'both']),
homeCampusId: id.nullable(),
createdAt: isoDate,
});
type MeRow = { id: string; name: string; email: string; phoneNumber: string | null; role: 'admin' | 'supervisor' | 'staff' | 'user'; twoFactorEnabled: boolean | null; notificationPreference: 'email' | 'whatsapp' | 'both'; homeCampusId: string | null; createdAt: Date };
const toMe = (u: MeRow): z.input<typeof me> => ({
id: u.id, name: u.name, email: u.email, phone: u.phoneNumber ?? null, role: u.role, twoFactorEnabled: !!u.twoFactorEnabled,
notificationPreference: u.notificationPreference, homeCampusId: u.homeCampusId ?? null, createdAt: iso(u.createdAt),
});
export const getMe = defineRoute({
method: 'get',
path: '/me',
@@ -25,33 +35,37 @@ export const getMe = defineRoute({
async handler({ user }) {
const u = await prisma.user.findUnique({
where: { id: user!.id },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, createdAt: true },
select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, notificationPreference: true, homeCampusId: true, createdAt: true },
});
if (!u) throw new ApiError('UNAUTHENTICATED', 'User not found');
return { data: { id: u.id, name: u.name, email: u.email, phone: u.phoneNumber ?? null, role: u.role, twoFactorEnabled: !!u.twoFactorEnabled, createdAt: iso(u.createdAt) } };
return { data: toMe(u) };
},
});
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'),
export const updateMe = defineRoute({
method: 'patch',
path: '/me',
tag: 'Me',
summary: 'Change my details',
description: 'Only what is sent changes. A new `password` needs `currentPassword`; it sends a security notice and stops other devices skipping the 2FA code. The answer has a fresh `token` (the old one keeps working).',
access: 'user',
body: z.object({
name: z.string().trim().min(1).max(200).optional(),
email: z.email().max(200).optional(),
phone: z.string().trim().max(40).nullable().optional().describe('null or empty removes it'),
notificationPreference: z.enum(['email', 'whatsapp', 'both']).optional(),
homeCampusId: id.nullable().optional().describe('While campuses are on'),
password: z.string().min(8).max(200).optional(),
currentPassword: z.string().max(200).optional(),
}),
response: single(me).extend({ token: z.string() }),
async handler({ body, user, req }) {
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
const { user: u, token } = await updateProfile(user!.id, { ...body, phoneNumber: body.phone }, { ip: getClientIp(req), userAgent: req.headers['user-agent'] });
return { data: toMe(u), token };
},
});
const r2 = (n: number) => Math.round(n * 100) / 100;
export const listMyRegistrations = defineRoute({
method: 'get',
path: '/me/registrations',
@@ -62,38 +76,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);
},
});
+187
View File
@@ -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 } };
},
});
+3 -1
View File
@@ -48,6 +48,8 @@ export type RouteDef<P extends Schema | undefined, Q extends Schema | undefined,
response: R;
/** HTTP status on success (default 200). */
status?: number;
/** Runs after the access check, e.g. a rate limiter. */
middleware?: RequestHandler[];
handler: (ctx: Ctx<Infer<P>, Infer<Q>, Infer<B>>) => Promise<z.input<R>> | z.input<R>;
};
@@ -100,6 +102,6 @@ export function mount(router: Router, list: AnyRoute[] = routes): void {
next(err);
}
};
router[def.method](def.path, ...ACCESS[def.access], run);
router[def.method](def.path, ...ACCESS[def.access], ...(def.middleware ?? []), run);
}
}
+188 -2
View File
@@ -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', () => ({
@@ -24,11 +24,32 @@ jest.mock('../src/middleware/authMiddleware', () => {
protect: (req: { user?: unknown }, res: { status(n: number): void }, next: (e?: Error) => void) => (signedIn ? ((req.user = signedIn), next()) : deny(401, 'Not authorized, no token')(req, res, next as (e: Error) => void)),
optionalAuth: (req: { user?: unknown }, _res: unknown, next: () => void) => { if (signedIn) req.user = signedIn; next(); },
admin: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (req.user?.role === 'admin' ? next() : deny(403, 'Not authorized as an admin')(req, res, next as (e: Error) => void)),
staff: jest.fn(), supervisor: jest.fn(),
staff: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (['admin', 'supervisor', 'staff'].includes(req.user?.role || '') ? next() : deny(403, 'Not authorized as staff')(req, res, next as (e: Error) => void)),
supervisor: jest.fn(),
loginLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
};
});
// 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) }));
const mockAuth = { signUp: jest.fn(), logIn: jest.fn(), verifyLoginCode: jest.fn(), updateProfile: jest.fn() };
jest.mock('../src/services/auth', () => ({
signUp: (...a: unknown[]) => mockAuth.signUp(...a), logIn: (...a: unknown[]) => mockAuth.logIn(...a),
verifyLoginCode: (...a: unknown[]) => mockAuth.verifyLoginCode(...a), updateProfile: (...a: unknown[]) => mockAuth.updateProfile(...a),
}));
const mockCheckin = { previewScan: jest.fn(), scanTicket: jest.fn() };
jest.mock('../src/services/checkin', () => ({ previewScan: (...a: unknown[]) => mockCheckin.previewScan(...a), scanTicket: (...a: unknown[]) => mockCheckin.scanTicket(...a) }));
const mockScope = { scope: null as object | null, campusId: 'north' as string | null };
jest.mock('../src/utils/campusScope', () => ({
scopeFor: async () => mockScope.scope,
eventOf: async () => ({ campusId: mockScope.campusId, campusName: 'North' }),
canView: (scope: { campusIds: string[] }, campusId: string | null) => !campusId || scope.campusIds.includes(campusId),
}));
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 +171,168 @@ 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());
});
});
describe('accounts', () => {
const sess = { id: 'u1', name: 'Thandi', email: 't@x.co', role: 'user', token: 'jwt', newsPromptPending: true, noticePromptPending: false };
test('log in: a token, or a code to finish with', async () => {
mockAuth.logIn.mockResolvedValueOnce({ status: 'signed_in', session: sess });
const ok = await request(app).post('/api/v2/auth/login').send({ identifier: 't@x.co', password: 'secret' });
expect(ok.body.data).toEqual({ status: 'signed_in', token: 'jwt', user: { id: 'u1', name: 'Thandi', email: 't@x.co', role: 'user' }, newsPromptPending: true, noticePromptPending: false });
mockAuth.logIn.mockResolvedValueOnce({ status: 'code_required', challenge: { challengeId: 'c1', method: 'email', destination: 't***@x.co' }, enrolling: true });
const code = await request(app).post('/api/v2/auth/login').send({ identifier: 't@x.co', password: 'secret' });
expect(code.body.data).toEqual({ status: 'code_required', challengeId: 'c1', method: 'email', destination: 't***@x.co', enrolling: true });
// The token never comes with a code challenge.
expect(code.body.data.token).toBeUndefined();
});
test('a wrong password is UNAUTHENTICATED and a locked account RATE_LIMITED', async () => {
mockAuth.logIn.mockRejectedValueOnce(new ServiceError(401, 'Invalid email or password', undefined, { v1Status: 400 }));
expect((await request(app).post('/api/v2/auth/login').send({ identifier: 'a', password: 'b' })).body.error.code).toBe('UNAUTHENTICATED');
mockAuth.logIn.mockRejectedValueOnce(new ServiceError(429, 'Too many attempts. Try again in 5 minute(s).'));
const locked = await request(app).post('/api/v2/auth/login').send({ identifier: 'a', password: 'b' });
expect([locked.status, locked.body.error.code]).toEqual([429, 'RATE_LIMITED']);
});
test('sign up needs the terms accepted and a password of 8+', async () => {
const bad = await request(app).post('/api/v2/auth/signup').send({ name: 'T', email: 't@x.co', password: 'short' });
expect(Object.keys(bad.body.error.fields).sort()).toEqual(['acceptTerms', 'password']);
mockAuth.signUp.mockResolvedValue({ id: 'u9', name: 'T', email: 't@x.co', role: 'user', token: 'jwt9' });
const ok = await request(app).post('/api/v2/auth/signup').send({ name: 'T', email: 't@x.co', password: 'long-enough', acceptTerms: true, newsOptIn: true });
expect(ok.status).toBe(201);
expect(ok.body.data).toMatchObject({ status: 'signed_in', token: 'jwt9' });
expect(mockAuth.signUp).toHaveBeenCalledWith(expect.objectContaining({ newsOptIn: true }), expect.any(Object));
});
test('PATCH /me changes only what is sent and hands back a fresh token', async () => {
signedIn = { id: 'u1', role: 'user' };
mockAuth.updateProfile.mockResolvedValue({ user: { id: 'u1', name: 'New', email: 't@x.co', phoneNumber: null, role: 'user', twoFactorEnabled: false, notificationPreference: 'email', homeCampusId: null, createdAt: new Date('2026-01-01T00:00:00Z'), tokenVersion: 0 }, token: 'jwt2' });
const res = await request(app).patch('/api/v2/me').send({ name: 'New' });
expect(res.body).toMatchObject({ data: { name: 'New', phone: null }, token: 'jwt2' });
expect(res.body.data.tokenVersion).toBeUndefined();
expect(mockAuth.updateProfile).toHaveBeenCalledWith('u1', { name: 'New', phoneNumber: undefined }, expect.any(Object));
});
});
describe('check-in', () => {
const ticket = { id: 't1', quantity: 2, isUsed: false, eventId: 'e1', event: { title: 'Youth Camp' }, registrationOption: { eventOption: { id: 'o1', name: 'Camper', isMainTicket: true } }, usages: [] };
afterEach(() => { mockScope.scope = null; });
test('staff only', async () => {
signedIn = { id: 'u1', role: 'user' };
expect((await request(app).post('/api/v2/checkin/QR1').send({})).body.error.code).toBe('FORBIDDEN');
});
test('every normal result is a 200 with an outcome', async () => {
signedIn = { id: 's1', role: 'staff' };
mockCheckin.scanTicket.mockResolvedValueOnce({ outcome: 'checked_in', message: 'Ticket scanned successfully (1 of 2 redeemed, 1 remaining)', session: null, ticketUsage: {}, ticket, qtyRedeemed: 1, totalRedeemed: 1, remaining: 1 });
const ok = await request(app).post('/api/v2/checkin/QR1').send({ people: 1, eventId: 'e1' });
expect(ok.body.data).toEqual({ outcome: 'checked_in', message: expect.any(String), ticket: { id: 't1', eventId: 'e1', eventTitle: 'Youth Camp', ticketName: 'Camper', quantity: 2 }, checkedIn: 1, remaining: 1, session: null, consentStatus: null, canOverride: false });
expect(mockCheckin.scanTicket).toHaveBeenCalledWith('QR1', signedIn, expect.objectContaining({ qty: 1, eventId: 'e1' }), expect.anything());
mockCheckin.scanTicket.mockResolvedValueOnce({ outcome: 'consent_required', message: 'Parental consent is missing', consentStatus: 'missing', canOverride: false, ticket });
const consent = await request(app).post('/api/v2/checkin/QR1').send({});
expect(consent.status).toBe(200);
expect(consent.body.data).toMatchObject({ outcome: 'consent_required', checkedIn: 0, consentStatus: 'missing' });
mockCheckin.scanTicket.mockResolvedValueOnce({ outcome: 'transferred', message: 'Given to Sipho' });
expect((await request(app).post('/api/v2/checkin/QR1').send({})).body.data).toMatchObject({ outcome: 'transferred', ticket: null });
});
test("campus-scoped staff can't check in another campus's ticket", async () => {
signedIn = { id: 's1', role: 'staff' };
mockScope.scope = { campusIds: ['south'] };
const res = await request(app).post('/api/v2/checkin/QR1').send({});
expect(res.body.error).toEqual({ code: 'FORBIDDEN', message: 'This ticket is for an event at North.' });
expect(mockCheckin.scanTicket).not.toHaveBeenCalled();
});
test('the preview names the holder and what is left', async () => {
signedIn = { id: 's1', role: 'staff' };
mockCheckin.previewScan.mockResolvedValue({ outcome: 'found', ticket: { ...ticket, consentStatus: 'not_required', remaining: 2, session: null, sessionError: null, registrationOption: { id: 'ro1', eventOption: { id: 'o1', name: 'Camper' }, variant: null }, event: { id: 'e1', title: 'Youth Camp' }, user: { id: 'u1', name: 'Thandi' }, attendee: { name: 'Lulu' } } });
const res = await request(app).get('/api/v2/checkin/QR1');
expect(res.body.data.ticket).toMatchObject({ holder: 'Lulu', remaining: 2, consentStatus: 'not_required' });
});
});
+18 -4
View File
@@ -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.2** (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,8 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
| 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
@@ -62,9 +64,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.3 — the rest of v2** (the scanner page onto `/checkin`, staff and admin screens),
**Express 5, v1 deprecation headers and v2-shaped global middleware** (rate limits, read-only
modes, campus refusals), then **2.0.0 — AI helpers** (§15).
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 +473,18 @@ 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.
- **Outcomes, not errors** (1.51.2): where an endpoint has several normal answers (scanning:
already used, consent missing…), v2 returns 200 with an `outcome` and keeps errors for real
failures. The service returns a union (`ScanResult`); `v1ScanResponse()` maps it to v1's statuses.
- **v1 statuses:** `ServiceError(status, message, code, { v1Status })` when v2 means something
different (wrong password: 401 in v2, 400 in v1); v1 controllers use `v1StatusOf(error, fallback)`.
- **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
View File
@@ -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**, 1.51.1 **v2 registrations and payments** and 1.51.2 **v2 accounts and check-in** shipped.
**Next: 1.51.3 — the rest of v2, Express 5.**
**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/`),
+49
View File
@@ -35,6 +35,55 @@ 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("log in, change my details, and check a ticket in", async ({ request }) => {
const bad = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "wrong-password" } });
expect(bad.status()).toBe(401);
expect((await bad.json()).error).toMatchObject({ code: "UNAUTHENTICATED", message: "Invalid email or password" });
const login = await (await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "E2e-password-123" } })).json();
expect(login.data).toMatchObject({ status: "signed_in", user: { id: seed().users.user.id, role: "user" } });
const headers = { Authorization: `Bearer ${login.data.token}` };
const changed = await (await request.patch(`${API}/api/v2/me`, { headers, data: { notificationPreference: "email" } })).json();
expect(changed.data).toMatchObject({ id: seed().users.user.id, notificationPreference: "email" });
expect(changed.token).toEqual(expect.any(String));
// A free ticket for the user, then check it in twice through v2.
const event = (await (await request.get(`${API}/api/v2/events/${seed().events.free}`)).json()).data;
await request.post(`${API}/api/v2/registrations`, { headers, data: { eventId: event.id, options: [{ eventOptionId: event.ticketOptions[0].id, quantity: 1 }] } });
const tickets = await (await request.get(`${API}/api/tickets/mytickets`, { headers })).json();
const qr = tickets.find((t: { eventId: string; qrCode: string }) => t.eventId === event.id).qrCode;
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
expect((await request.post(`${API}/api/v2/checkin/${qr}`, { headers, data: {} })).status()).toBe(403);
const look = await (await request.get(`${API}/api/v2/checkin/${qr}`, { headers: staff })).json();
expect(look.data).toMatchObject({ outcome: "found", ticket: { eventId: event.id } });
expect(look.data.ticket.remaining).toBeGreaterThan(0);
const scanned = await (await request.post(`${API}/api/v2/checkin/${qr}`, { headers: staff, data: { eventId: event.id } })).json();
expect(scanned.data).toMatchObject({ outcome: "checked_in", remaining: 0 });
const again = await (await request.post(`${API}/api/v2/checkin/${qr}`, { headers: staff, data: {} })).json();
expect(again.data).toMatchObject({ outcome: "already_used", checkedIn: 0 });
expect((await (await request.post(`${API}/api/v2/checkin/NOT-A-CODE`, { headers: staff, data: {} })).json()).error.code).toBe("NOT_FOUND");
});
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);
+243 -5
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.0",
"version": "1.51.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.51.0",
"version": "1.51.2",
"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",
+4 -2
View File
@@ -1,12 +1,13 @@
{
"name": "hope-events-frontend",
"version": "1.51.0",
"version": "1.51.2",
"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 -4
View File
@@ -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);
@@ -12,6 +12,7 @@ import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn,
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
import { apiV2 } from "@/lib/apiV2";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
@@ -61,19 +62,18 @@ export default function UserProfilePage() {
setSavingProfile(true);
setProfileMsg(null);
try {
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
method: "PUT",
const res = await apiV2("patch", "/me", {
authToken: token,
body: {
name,
email,
phoneNumber: phone || null,
phone: phone || null,
notificationPreference: hasValidPhone ? notifPref : "email",
...(campusInfo.enabled ? { homeCampusId: homeCampusId || null } : {}),
},
});
// Backend returns a refreshed token — save it so the session stays valid
if (res?.token) updateToken(res.token);
// A refreshed token comes back — save it so the session stays valid
updateToken(res.token);
setProfileMsg({ type: "ok", text: "Profile updated." });
} catch (e) {
setProfileMsg({ type: "err", text: errorMessage(e) || "Failed to update profile." });
@@ -103,12 +103,8 @@ export default function UserProfilePage() {
setSavingPw(true);
setPwMsg(null);
try {
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
method: "PUT",
authToken: token,
body: { currentPassword, password: newPassword },
});
if (res?.token) updateToken(res.token);
const res = await apiV2("patch", "/me", { authToken: token, body: { currentPassword, password: newPassword } });
updateToken(res.token);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
@@ -1,12 +1,12 @@
"use client";
import React, { useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
import { KeyRound } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import { apiV2 } from "@/lib/apiV2";
export default function ResetPasswordPage() {
const { token } = useAuth();
@@ -31,7 +31,7 @@ export default function ResetPasswordPage() {
if (!canSubmit) return;
try {
setLoading(true);
await apiFetch("/api/users/profile", { method: "PUT", authToken: token, body: { password, currentPassword: current } });
await apiV2("patch", "/me", { authToken: token, body: { password, currentPassword: current } });
setStatus("Your password has been updated successfully.");
setCurrent("");
setPassword("");
@@ -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)}`;
+27 -15
View File
@@ -2,7 +2,15 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthResponse, AuthUser, TwoFactorChallenge, clearToken, getDeviceToken, getToken, isTwoFactorChallenge, saveDeviceToken, saveToken } from "@/lib/auth";
import { AuthResponse, AuthUser, TwoFactorChallenge, clearToken, getDeviceToken, getToken, saveDeviceToken, saveToken } from "@/lib/auth";
import { apiV2, type V2Result } from "@/lib/apiV2";
// A v2 "signed_in" answer as the session the app keeps (the user's fields plus the token).
type V2Session = Extract<V2Result<"/auth/login", "post">["data"], { status: "signed_in" }>;
const toAuthResponse = (s: V2Session): AuthResponse => ({
...s.user, token: s.token, newsPromptPending: s.newsPromptPending, noticePromptPending: s.noticePromptPending,
...(s.deviceToken ? { deviceToken: s.deviceToken } : {}),
});
export type AuthContextValue = {
user: AuthUser | null;
@@ -91,22 +99,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);
const login = useCallback(async (email: string, password: string) => {
const res = await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
method: "POST",
body: { email, password, deviceToken: getDeviceToken() || undefined },
const { data } = await apiV2("post", "/auth/login", {
body: { identifier: email, password, deviceToken: getDeviceToken() || undefined },
});
// 2FA accounts get a challenge back instead of a token — nothing is saved
// until verifyTwoFactor exchanges the emailed/WhatsApp code for one.
if (isTwoFactorChallenge(res)) return res;
return startSession(res as AuthResponse);
if (data.status === "code_required") {
const challenge: TwoFactorChallenge = { twoFactorRequired: true, challengeId: data.challengeId, method: data.method, destination: data.destination || "", ...(data.enrolling ? { enrolling: true } : {}) };
return challenge;
}
return startSession(toAuthResponse(data));
}, [startSession]);
const verifyTwoFactor = useCallback(async (challengeId: string, code: string, rememberDevice: boolean) => {
const res = await apiFetch<AuthResponse>("/api/users/login/2fa", {
method: "POST",
body: { challengeId, code, rememberDevice },
});
return startSession(res);
const { data } = await apiV2("post", "/auth/login/code", { body: { challengeId, code, rememberDevice } });
return startSession(toAuthResponse(data));
}, [startSession]);
const resendTwoFactor = useCallback((challengeId: string) => {
@@ -117,11 +124,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);
const register = useCallback(async (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both"; newsOptIn?: boolean }) => {
const res = await apiFetch<AuthResponse>("/api/users", {
method: "POST",
body: data,
// The sign-up form only submits once "I agree to the Terms of Use and Privacy Policy" is ticked.
const { data: made } = await apiV2("post", "/auth/signup", {
body: {
name: data.name, email: data.email, password: data.password, acceptTerms: true,
...(data.phoneNumber ? { phone: data.phoneNumber } : {}),
...(data.notificationPreference ? { notificationPreference: data.notificationPreference } : {}),
...(data.newsOptIn !== undefined ? { newsOptIn: data.newsOptIn } : {}),
},
});
const { token, ...registeredUser } = res;
const { token, ...registeredUser } = toAuthResponse(made);
saveToken(token);
setToken(token);
setUser(registeredUser);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17 -9
View File
@@ -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 };
}
+44
View File
@@ -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 });
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.51.0",
"version": "1.51.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.51.0",
"version": "1.51.2",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.51.0",
"version": "1.51.2",
"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": "",