Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4109cb4258 | ||
|
|
ce9734090c | ||
|
|
9012ec64b6 | ||
|
|
7c252e0cb3 | ||
|
|
735a76cc74 | ||
|
|
e2e881be85 |
@@ -7,6 +7,74 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.51.3] - 2026-10-29
|
||||
|
||||
### Changed
|
||||
|
||||
- **Express 5** (from 4). Kept Express 4 behaviour where it changed: the `qs` ("extended") query
|
||||
parser, and `req.body` as `{}` when a request has no JSON body. `@types/express` 5.
|
||||
- **The ticket scanner and at-the-door check-in use API v2** (`/api/v2/checkin/{qrCode}`). The v2
|
||||
look-up now gives `attendee`, `bookedBy` and `ticketOptionId` (for section checks).
|
||||
- **Shared middleware answers in the v2 shape on /api/v2:** rate limits, the impersonation and
|
||||
licence read-only guards, the auth checks and campus refusals write v1's `{ message, code }`;
|
||||
on v2 that becomes `{ error: { code, message, reason } }` (`reason` = the specific old code, e.g.
|
||||
`TWO_FACTOR_REQUIRED`, `LICENCE_READ_ONLY`). Read-only answers have the v2 code `READ_ONLY` (403).
|
||||
The frontend's `apiFetch` treats `reason` like v1's `code`, so the 2FA and impersonation
|
||||
handling works on v2 calls too.
|
||||
|
||||
### Added
|
||||
|
||||
- **v1 deprecation headers:** v1 endpoints that have a v2 replacement answer with
|
||||
`Deprecation: @<date>` (RFC 9745) and `Link: <…>; rel="successor-version"` (`src/v2/deprecation.ts`).
|
||||
The rest of v1 has no replacement yet, so no header.
|
||||
|
||||
## [1.51.2] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
|
||||
+6
-1
@@ -890,7 +890,12 @@ 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 scanner and at-the-door use it from 1.51.3); the frontend's v2 calls are typed from the
|
||||
OpenAPI document. v1 endpoints with a v2 replacement send `Deprecation` and a `successor-version`
|
||||
link (1.51.3).
|
||||
- **TypeScript backend** (1.51.0): `.ts` files are checked strictly, `.js` compiles as it is;
|
||||
deploys build `dist/`, which `src/index.js` runs.
|
||||
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
|
||||
|
||||
@@ -12,6 +12,22 @@ Write for church admins, not developers: no environment variables, endpoints or
|
||||
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
|
||||
-->
|
||||
|
||||
## 1.51.3
|
||||
|
||||
- **More groundwork for 2.0.** The ticket scanner and check-in at the door now run on the new version
|
||||
of the server's connection to the app, and the server's web framework has been updated. They work
|
||||
just as before.
|
||||
|
||||
## 1.51.2
|
||||
|
||||
- **More groundwork for 2.0.** Logging in, signing up and changing your details now run on the
|
||||
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
|
||||
|
||||
@@ -6,7 +6,9 @@ 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/…`). v1 endpoints that have a v2 replacement send `Deprecation` and
|
||||
> `Link: <v2 path>; rel="successor-version"` headers (1.51.3). The frontend calls v2 through the typed
|
||||
> `apiV2()` helper (`frontend/src/lib/apiV2.ts`, types regenerated with `npm run api:types`).
|
||||
|
||||
All authenticated requests require:
|
||||
```
|
||||
|
||||
Generated
+393
-528
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.3",
|
||||
"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",
|
||||
@@ -28,7 +29,7 @@
|
||||
"cron-parser": "^4.9.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.18.2",
|
||||
"express": "^5.3.0",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"ics": "^3.12.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -46,7 +47,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.19.43",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -31,6 +31,9 @@ const getRawBody = require('raw-body');
|
||||
// Initialize Express app
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
// Express 5 parses query strings the simple way by default; keep Express 4's (qs) so
|
||||
// ?a[b]=1 style filters keep working.
|
||||
app.set('query parser', 'extended');
|
||||
|
||||
// Trust proxy — required when running behind a reverse proxy (e.g. nginx on a
|
||||
// separate server) so req.ip / X-Forwarded-For are read correctly by
|
||||
@@ -98,6 +101,12 @@ const globalLimiter = rateLimit({
|
||||
message: { message: 'Too many requests, please try again later.' },
|
||||
skip: (req) => req.path.startsWith('/api/webhooks'),
|
||||
});
|
||||
// /api/v2 answers every error as { error: { code, message } }, including the shared
|
||||
// middleware below (rate limits, read-only guards, auth) that writes v1's { message }.
|
||||
app.use('/api/v2', require('./v2/errors').legacyErrorsToV2);
|
||||
// v1 endpoints with a v2 replacement say so (Deprecation + Link: rel="successor-version").
|
||||
app.use('/api', require('./v2/deprecation').deprecationHeaders);
|
||||
|
||||
app.use(globalLimiter);
|
||||
|
||||
// Impersonation is strictly read-only: refuse every write made with an impersonation
|
||||
@@ -155,6 +164,11 @@ app.use('/api/webhooks', webhookRoutes);
|
||||
|
||||
// Apply JSON body parser for all non-webhook routes
|
||||
app.use(express.json());
|
||||
// Express 5 leaves req.body undefined when a request has no JSON body; handlers expect {}.
|
||||
app.use((req, res, next) => {
|
||||
if (req.body === undefined) req.body = {};
|
||||
next();
|
||||
});
|
||||
|
||||
// Count server errors (any 5xx response) for the Hub heartbeat.
|
||||
app.use((req, res, next) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ const { computeDonationRemaining } = require('../utils/donationUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const payments = require('../payments');
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
@@ -941,54 +940,14 @@ const createCheckout = async (req, res) => {
|
||||
throw new Error('Either registrationId or eventId is required');
|
||||
}
|
||||
|
||||
// A registration's balance
|
||||
// A registration's balance (shared with POST /api/v2/registrations/:id/checkout)
|
||||
if (registrationId) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
|
||||
if (!reg) { res.status(404); throw new Error('Registration not found'); }
|
||||
if (reg.userId !== userId && req.user.role !== 'admin' && req.user.role !== 'supervisor') {
|
||||
res.status(403); throw new Error('Not authorized to create checkout for this registration');
|
||||
}
|
||||
// If event is in the past, block self-service payment (mirrors the registration-edit block)
|
||||
if (req.user.role !== 'admin' && req.user.role !== 'supervisor' && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
|
||||
res.status(400); throw new Error('This event has already ended; payment can no longer be made for this registration');
|
||||
}
|
||||
await assertEventOpen(reg.eventId, res);
|
||||
|
||||
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
|
||||
// Must run before the balance is worked out so the checkout charges the right price.
|
||||
const { changed: priceChanged } = await refreshPricingForRegistration(registrationId);
|
||||
if (priceChanged) {
|
||||
const freshReg = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
const newTotal = computeRegistrationTotalDue(freshReg, new Date());
|
||||
const totalPaid = (freshReg.payments || []).reduce((s, p) => s + p.amount, 0);
|
||||
return res.status(200).json({
|
||||
priceUpdated: true,
|
||||
newTotal: Math.max(0, newTotal - totalPaid),
|
||||
message: 'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.'
|
||||
});
|
||||
}
|
||||
|
||||
// Part of the balance (supervisor payments page)
|
||||
if (amount && !isNaN(parseFloat(amount))) {
|
||||
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, amount: parseFloat(amount), provider });
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
// Optional donation added at registration ("Donation amount" on the register form)
|
||||
let donation = 0;
|
||||
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
|
||||
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
|
||||
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
|
||||
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) { res.status(400); throw new Error(`Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`); }
|
||||
}
|
||||
|
||||
const result = await createRegistrationCheckout(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation, provider });
|
||||
const { startRegistrationCheckout } = require('../services/payments');
|
||||
const result = await startRegistrationCheckout(registrationId, req.user, {
|
||||
amount: amount && !isNaN(parseFloat(amount)) ? parseFloat(amount) : null,
|
||||
donationAmount: donationAmount === undefined || donationAmount === null || donationAmount === '' ? null : parseFloat(donationAmount),
|
||||
successUrl, cancelUrl, failureUrl, provider,
|
||||
});
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,534 +3,33 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { hashPassword } = require('../config/auth');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
|
||||
const { lineKey, validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
|
||||
const { previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
|
||||
const { validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
|
||||
const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { ensureConsentRequests } = require('../utils/consent');
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const { findOfferByToken, queueWaitlist } = require('../utils/waitlist');
|
||||
const { queueWaitlist } = require('../utils/waitlist');
|
||||
const { reservedOptionIds } = require('../utils/volunteers');
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
function queueConsentRequests(registrationId) {
|
||||
ensureConsentRequests(registrationId).catch(e => console.error('[consent] Failed to send requests:', e?.message || e));
|
||||
}
|
||||
|
||||
// Named attendees for API responses, with whether their ticket has been issued/used.
|
||||
const ATTENDEES_INCLUDE = {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ticket: { select: { id: true, isUsed: true } },
|
||||
// Latest consent request (youth events) — it decides check-in.
|
||||
consentRequests: { orderBy: { createdAt: 'desc' }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
|
||||
},
|
||||
};
|
||||
|
||||
const TICKET_DELIVERY = ['payer', 'each'];
|
||||
|
||||
/**
|
||||
* Places each line will have once `requested` is added to `existingLines` — used to
|
||||
* check attendee names against what's actually being bought.
|
||||
*/
|
||||
function placesAfter(existingLines, requested) {
|
||||
const out = {};
|
||||
// Picked-session lines take no attendee names.
|
||||
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
|
||||
const k = lineKey(l.eventOptionId, l.variantId);
|
||||
out[k] = (out[k] || 0) + Number(l.quantity || 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Active coupon on a registration, for API responses (the discount itself is
|
||||
// Registration.discountTotal).
|
||||
const ACTIVE_COUPONS_INCLUDE = {
|
||||
where: { releasedAt: null },
|
||||
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
|
||||
};
|
||||
// Shared with POST /api/v2/registrations: the registration service (TypeScript, 1.51.1).
|
||||
const registrationService = require('../services/registrations');
|
||||
const {
|
||||
queueConsentRequests, ATTENDEES_INCLUDE, TICKET_DELIVERY, placesAfter, ACTIVE_COUPONS_INCLUDE,
|
||||
pendingCouponLines, priceRequestedOption, checkOptionStock, checkVariantStock, isStaffRole,
|
||||
} = registrationService;
|
||||
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
|
||||
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption, mergeSessionIds } = require('../utils/sessions');
|
||||
|
||||
/**
|
||||
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
||||
* before anything is created. Mirrors buildCouponLines for one tranche per option.
|
||||
*/
|
||||
function pendingCouponLines(event, resolvedOptions) {
|
||||
return buildCouponLines({
|
||||
registrationOptions: resolvedOptions.map(o => ({
|
||||
eventOptionId: o.eventOptionId,
|
||||
variantId: o.variantId || null,
|
||||
quantity: o.quantity,
|
||||
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
|
||||
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
|
||||
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
|
||||
*/
|
||||
async function priceRequestedOption(eventOption, variantId, qty, buyer) {
|
||||
try {
|
||||
const resolved = variantId
|
||||
? await resolveVariantTierPrice(eventOption, variantId, qty, buyer)
|
||||
: await resolveOptionPrice(eventOption, qty, buyer);
|
||||
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
|
||||
} catch (e) {
|
||||
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
|
||||
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
|
||||
return { priceSnapshot: Number(price || 0), appliedTierId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check overall stock availability for an EventOption. Places held for live waitlist
|
||||
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
||||
* Returns { available: boolean, remaining: number|null }
|
||||
*/
|
||||
async function checkOptionStock(option, requestedQty, excludeEntryId = null, sessionId = null) {
|
||||
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check stock availability for a specific variant (same rules as checkOptionStock).
|
||||
*/
|
||||
async function checkVariantStock(variant, requestedQty, excludeEntryId = null, sessionId = null) {
|
||||
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
const { expandPickLines, assertSessionCapacity, isPickOption, mergeSessionIds } = require('../utils/sessions');
|
||||
|
||||
// @desc Create a new registration
|
||||
// @route POST /api/registrations
|
||||
// @access Private
|
||||
const createRegistration = async (req, res) => {
|
||||
try {
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = req.body;
|
||||
|
||||
// Check if event exists
|
||||
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
||||
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
||||
const optionInclude = canIncludeTiers
|
||||
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: true } : {}) }
|
||||
: true;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: true }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
if (!event.isActive) {
|
||||
res.status(400);
|
||||
throw new Error('Event is not active');
|
||||
}
|
||||
|
||||
if (event.cashupStatus === 'closed') {
|
||||
res.status(400);
|
||||
throw new Error('This event is closed and no longer accepting registrations.');
|
||||
}
|
||||
|
||||
// A waitlist claim link: its token identifies the person and releases the places
|
||||
// held for them. It works without logging in, the same way a consent link does.
|
||||
let waitlistOffer = null;
|
||||
if (waitlistToken) {
|
||||
waitlistOffer = await findOfferByToken(waitlistToken);
|
||||
if (!waitlistOffer || waitlistOffer.eventId !== eventId) { res.status(400); throw new Error('This waitlist link is not valid.'); }
|
||||
if (waitlistOffer.status !== 'offered') { res.status(400); throw new Error(`This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`); }
|
||||
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) { res.status(400); throw new Error('This waitlist offer has expired.'); }
|
||||
if (req.user && req.user.id !== waitlistOffer.userId) { res.status(403); throw new Error('This waitlist offer belongs to someone else.'); }
|
||||
}
|
||||
|
||||
// Enforce auth unless event explicitly opts out
|
||||
const eventRequiresAuth = event.requiresAuth !== false;
|
||||
if (eventRequiresAuth && !req.user && !waitlistOffer) {
|
||||
res.status(401);
|
||||
throw new Error('Authentication required for this event');
|
||||
}
|
||||
|
||||
// Unauthenticated guest registration is only permitted for free events
|
||||
if (!req.user && !waitlistOffer) {
|
||||
const totalDueCheck = (event.eventOptions || []).reduce((sum, eo) => sum + (eo.price || 0), 0);
|
||||
// Also check against the requested options specifically
|
||||
const requestedTotal = (options || []).reduce((sum, opt) => {
|
||||
const eo = event.eventOptions.find(o => o.id === opt.eventOptionId);
|
||||
return sum + ((eo?.price || 0) * (opt.quantity || 1));
|
||||
}, 0);
|
||||
if (requestedTotal > 0) {
|
||||
res.status(400);
|
||||
throw new Error('Guest registration is only available for free events. Please create an account to pay for tickets.');
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve userId — authenticated or guest
|
||||
let userId;
|
||||
if (req.user) {
|
||||
userId = req.user.id;
|
||||
} else if (waitlistOffer) {
|
||||
userId = waitlistOffer.userId;
|
||||
} else {
|
||||
// Guest registration: find existing user by email or create inactive placeholder
|
||||
if (!guestEmail || !guestName) {
|
||||
res.status(400);
|
||||
throw new Error('Name and email are required for unauthenticated registration');
|
||||
}
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) {
|
||||
res.status(409);
|
||||
throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.');
|
||||
}
|
||||
const existingGuest = match.user;
|
||||
if (existingGuest) {
|
||||
userId = existingGuest.id;
|
||||
} else {
|
||||
const newGuest = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name: guestName,
|
||||
email: guestEmail,
|
||||
password: '',
|
||||
phoneNumber: guestPhone || null,
|
||||
isActive: false,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
});
|
||||
userId = newGuest.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce registration cutoff for public registrations (multi-day events close when
|
||||
// the first session starts, or later with late joining — see utils/sessions.js).
|
||||
const now = new Date();
|
||||
if (now >= registrationClosesAt(event, event.sessions)) {
|
||||
res.status(400);
|
||||
throw new Error('Registration is closed for this event');
|
||||
}
|
||||
|
||||
// Validate options
|
||||
if (!options || !Array.isArray(options) || options.length === 0) {
|
||||
res.status(400);
|
||||
throw new Error('At least one option must be selected');
|
||||
}
|
||||
|
||||
// "Pick sessions" options become one line per chosen session; whole-course tickets
|
||||
// on such an event still close when the whole course does.
|
||||
const lines = expandPickLines(event, options, { now });
|
||||
// (An all-sessions ticket with sessions ticked is checked per session instead.)
|
||||
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && now >= wholeCourseClosesAt(event, event.sessions)) {
|
||||
res.status(400);
|
||||
throw new Error('Whole-course tickets are no longer on sale — choose the sessions you want instead.');
|
||||
}
|
||||
await assertSessionCapacity(event, lines, undefined, { excludeEntryId: waitlistOffer?.id });
|
||||
|
||||
// Validate each option and resolve prices / check stock.
|
||||
// Options are independent of each other, so resolve them concurrently. Errors are marked
|
||||
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
|
||||
// across concurrent iterations) — the outer catch always responds 400 regardless.
|
||||
// The kids check-in and volunteer tickets are handed out by staff, never sold.
|
||||
const reserved = new Set(isStaffRole(req.user?.role) ? [] : reservedOptionIds(event));
|
||||
const resolvedOptions = await Promise.all(lines.map(async (option) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption || reserved.has(eventOption.id)) {
|
||||
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
|
||||
}
|
||||
if (!option.quantity || option.quantity < 1) {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
|
||||
const qty = option.quantity;
|
||||
|
||||
// Check overall option stock
|
||||
try {
|
||||
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id, option.sessionId);
|
||||
if (!stockCheck.available) {
|
||||
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.isStockError) throw e; // propagate stock errors
|
||||
// If stock check function fails (pre-migration), continue without stock check
|
||||
}
|
||||
|
||||
// Check variant stock (if a variant is selected)
|
||||
let variantId = option.variantId || null;
|
||||
let variantPrice = null;
|
||||
if (variantId && canIncludeVariants) {
|
||||
const variant = (eventOption.variants || []).find(v => v.id === variantId);
|
||||
if (!variant) {
|
||||
throw new Error(`Variant not found for option "${eventOption.name}"`);
|
||||
}
|
||||
try {
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId);
|
||||
if (!vStock.available) {
|
||||
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
err.isStockError = true;
|
||||
throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.isStockError) throw e;
|
||||
}
|
||||
variantPrice = variant.price; // null = use option price
|
||||
}
|
||||
|
||||
// Resolve price: variant-level tiers take priority, then option-level tiers, then
|
||||
// base prices, then the staff price for Staff and above.
|
||||
let priceSnapshot = null;
|
||||
let appliedTierId = null;
|
||||
if (canIncludeTiers) {
|
||||
const buyer = req.user || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
|
||||
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, buyer));
|
||||
} else {
|
||||
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
|
||||
}
|
||||
if (priceSnapshot === null) priceSnapshot = eventOption.price;
|
||||
|
||||
return { ...option, variantId, appliedTierId, priceSnapshot };
|
||||
}));
|
||||
|
||||
// Check a coupon code before anything is saved, so a bad code doesn't leave a
|
||||
// half-made registration behind. It's applied (and re-checked under a lock) below.
|
||||
if (couponCode) {
|
||||
const buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
await previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
|
||||
}
|
||||
|
||||
// Check for existing non-cancelled registration for this user+event → merge instead
|
||||
const existingReg = await prisma.registration.findFirst({
|
||||
where: { userId, eventId, status: { not: 'cancelled' } },
|
||||
include: { registrationOptions: true }
|
||||
});
|
||||
|
||||
// Attendee names are checked against the places the registration will have after
|
||||
// this purchase, counting names already given on an existing registration.
|
||||
const cleanAttendees = validateAttendees(
|
||||
event,
|
||||
placesAfter(existingReg?.registrationOptions, resolvedOptions),
|
||||
attendees,
|
||||
existingReg ? await namedPlacesByKey(existingReg.id) : {}
|
||||
);
|
||||
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
|
||||
res.status(400);
|
||||
throw new Error("ticketDelivery must be 'payer' or 'each'");
|
||||
}
|
||||
|
||||
let registration;
|
||||
let isNewRegistration = false;
|
||||
if (existingReg) {
|
||||
// Merge: add a new price tranche per requested option into the existing registration.
|
||||
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
|
||||
// tickets bought at different early-bird prices into a single (wrong) price. Each
|
||||
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
|
||||
for (const opt of resolvedOptions) {
|
||||
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
|
||||
);
|
||||
if (existing) {
|
||||
await prisma.$transaction([
|
||||
prisma.registrationOptionTranche.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
registrationOptionId: existing.id,
|
||||
quantity: opt.quantity,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
}
|
||||
}),
|
||||
prisma.registrationOption.update({
|
||||
where: { id: existing.id },
|
||||
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: mergeSessionIds(existing.sessionIds, opt.sessionIds) }) }
|
||||
})
|
||||
]);
|
||||
} else {
|
||||
await prisma.registrationOption.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
registrationId: existingReg.id,
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
sessionIds: opt.sessionIds || [],
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: {
|
||||
create: [{
|
||||
id: uuidv4(),
|
||||
quantity: opt.quantity,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
}]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Only downgrade from paid if the newly added items actually cost something
|
||||
const newItemsCost = resolvedOptions.reduce((sum, opt) => {
|
||||
return sum + ((opt.priceSnapshot || 0) * (opt.quantity || 0));
|
||||
}, 0);
|
||||
if (existingReg.status === 'paid' && newItemsCost > 0) {
|
||||
await prisma.registration.update({ where: { id: existingReg.id }, data: { status: 'partial_paid', updatedAt: new Date() } });
|
||||
} else {
|
||||
await prisma.registration.update({ where: { id: existingReg.id }, data: { updatedAt: new Date() } });
|
||||
}
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: existingReg.id },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
isNewRegistration = true;
|
||||
// Create new registration
|
||||
const registrationId = uuidv4();
|
||||
registration = await prisma.registration.create({
|
||||
data: {
|
||||
id: registrationId,
|
||||
userId,
|
||||
eventId,
|
||||
updatedAt: new Date(),
|
||||
registrationOptions: {
|
||||
create: resolvedOptions.map(option => ({
|
||||
id: uuidv4(),
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
sessionIds: option.sessionIds || [],
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: {
|
||||
create: [{
|
||||
id: uuidv4(),
|
||||
quantity: option.quantity,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
}]
|
||||
}
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cleanAttendees.length > 0) {
|
||||
await createAttendees(registration.id, cleanAttendees);
|
||||
queueConsentRequests(registration.id);
|
||||
}
|
||||
if (waitlistOffer) {
|
||||
await prisma.waitlistEntry.update({
|
||||
where: { id: waitlistOffer.id },
|
||||
data: { status: 'claimed', claimedAt: new Date(), registrationId: registration.id, offerTokenHash: null },
|
||||
});
|
||||
}
|
||||
if (ticketDelivery) {
|
||||
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
||||
}
|
||||
|
||||
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
|
||||
let couponError = null;
|
||||
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
|
||||
if (couponCode) {
|
||||
try {
|
||||
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
|
||||
} catch (e) {
|
||||
// Lost a race for the last use since the check above — keep the registration.
|
||||
if (!e.isCouponError) throw e;
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
if (couponCode || hasDiscount || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registration.id },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
||||
|
||||
bundleApplications: BUNDLE_APPS_INCLUDE,
|
||||
attendees: ATTENDEES_INCLUDE,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If total due is zero, mark as paid and generate/update tickets if no required form
|
||||
let freeTicketMockReq = null, freeTicketMockRes = null;
|
||||
try {
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
if (totalDue === 0) {
|
||||
// Mark paid (no-op if already paid)
|
||||
if (registration.status !== 'paid') {
|
||||
await prisma.registration.update({ where: { id: registration.id }, data: { status: 'paid', updatedAt: new Date() } });
|
||||
}
|
||||
// check if event has a required form
|
||||
let hasRequiredForm = false;
|
||||
try {
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
|
||||
hasRequiredForm = !!(form && form.isRequired);
|
||||
} catch {}
|
||||
if (!hasRequiredForm) {
|
||||
// Same generator as paid registrations: a ticket per named attendee, and one
|
||||
// shared quantity ticket for the rest of each line.
|
||||
await generateTicketsForRegistration(registration.id);
|
||||
// Capture for chained email — tickets sent after registration confirmation
|
||||
freeTicketMockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
|
||||
freeTicketMockRes = { status: () => freeTicketMockRes, json: () => {} };
|
||||
}
|
||||
|
||||
// reflect status change in the returned object
|
||||
registration.status = 'paid';
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore errors in auto-generation path; manual generation remains available
|
||||
}
|
||||
|
||||
// Fire-and-forget: registration email first, then tickets (guarantees order)
|
||||
const { sendRegistrationEmails, sendRegistrationUpdatedEmails } = require('../utils/notifications');
|
||||
const _regId = registration.id;
|
||||
const _freeTicketReq = freeTicketMockReq;
|
||||
const _freeTicketRes = freeTicketMockRes;
|
||||
(async () => {
|
||||
try {
|
||||
if (isNewRegistration) {
|
||||
await sendRegistrationEmails(_regId);
|
||||
} else {
|
||||
await sendRegistrationUpdatedEmails(_regId);
|
||||
}
|
||||
} catch (e) { console.error('Failed to send registration emails:', e); }
|
||||
if (_freeTicketReq) {
|
||||
try { await emailTickets(_freeTicketReq, _freeTicketRes); } catch (e) { console.error('Error emailing tickets for free registration:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Funnel: this registration came from a tracked (cookie-consented) visit.
|
||||
if (analyticsSessionId) {
|
||||
const { recordSubmit } = require('../utils/analytics');
|
||||
recordSubmit(registration, analyticsSessionId).catch(e => console.warn('[analytics] submit not recorded:', e?.message || e));
|
||||
}
|
||||
|
||||
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
|
||||
const actor = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
const { registration, couponError } = await registrationService.createRegistration(req.body || {}, actor);
|
||||
res.status(201).json({ ...registration, ...(couponError ? { couponError } : {}) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -827,71 +326,7 @@ const updateRegistrationStatus = async (req, res) => {
|
||||
// @access Private
|
||||
const cancelRegistration = async (req, res) => {
|
||||
try {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { event: { select: { endDate: true } } }
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
res.status(404);
|
||||
throw new Error('Registration not found');
|
||||
}
|
||||
|
||||
// Check if user is authorized to cancel this registration
|
||||
if (registration.userId !== req.user.id && req.user.role !== 'admin') {
|
||||
res.status(403);
|
||||
throw new Error('Not authorized to cancel this registration');
|
||||
}
|
||||
|
||||
// If event is in the past, block self-cancellation (mirrors the edit-block above)
|
||||
if (req.user.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
|
||||
res.status(400);
|
||||
throw new Error('This event has already ended; registration can no longer be cancelled');
|
||||
}
|
||||
|
||||
await assertEventOpen(registration.eventId, res);
|
||||
|
||||
// Check if registration can be cancelled
|
||||
if (registration.status === 'cancelled') {
|
||||
res.status(400);
|
||||
throw new Error('Registration is already cancelled');
|
||||
}
|
||||
|
||||
// Non-admins cannot cancel a registration that has payments against it
|
||||
if (req.user.role !== 'admin') {
|
||||
const paymentCount = await prisma.payment.count({
|
||||
where: { registrationId: registration.id, amount: { gt: 0 } }
|
||||
});
|
||||
if (paymentCount > 0) {
|
||||
res.status(403);
|
||||
throw new Error('This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.');
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRegistration = await prisma.registration.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
status: 'cancelled',
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
await releaseRegistrationCoupons(req.params.id);
|
||||
queueWaitlist(registration.eventId);
|
||||
|
||||
// Only log when staff cancels on someone else's behalf — a routine self-service
|
||||
// cancellation isn't an admin action worth cluttering the audit trail with.
|
||||
if (registration.userId !== req.user.id) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'registration_cancelled',
|
||||
targetType: 'Registration',
|
||||
targetId: req.params.id,
|
||||
metadata: { registrationOwnerId: registration.userId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
const updatedRegistration = await registrationService.cancelRegistration(req.params.id, req.user, getClientIp(req));
|
||||
res.json({ message: 'Registration cancelled', registration: updatedRegistration });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
@@ -2153,7 +1588,6 @@ async function syncStatusAfterDiscount(registrationId) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const isStaffRole = (role) => ['admin', 'supervisor', 'staff'].includes(role);
|
||||
|
||||
// @desc Apply a coupon code to an existing registration
|
||||
// @route POST /api/registrations/:id/coupon
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Paying a registration online (1.51.1). Moved from paymentController.createCheckout so
|
||||
* POST /api/payments/checkout (v1) and POST /api/v2/registrations/:id/checkout share it.
|
||||
*/
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const payments = require('../payments') as { MIN_ONLINE_AMOUNT: number };
|
||||
const { createRegistrationCheckout } = require('../payments/checkouts') as {
|
||||
createRegistrationCheckout(registrationId: string, userId: string, opts: { successUrl?: string; cancelUrl?: string; failureUrl?: string; donationAmount?: number; amount?: number | null; provider?: string | null }): Promise<{
|
||||
checkoutId: string; redirectUrl: string; provider: string; amount: number; registration: { id: string; eventId: string; eventTitle: string };
|
||||
}>;
|
||||
};
|
||||
const pricing = require('../utils/pricing') as {
|
||||
refreshPricingForRegistration(id: string): Promise<{ changed: boolean }>;
|
||||
computeRegistrationTotalDue(registration: any, at: Date): number;
|
||||
};
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string): Promise<unknown> };
|
||||
|
||||
export type CheckoutOptions = {
|
||||
/** Pay only this much of the balance (staff taking part payments). */
|
||||
amount?: number | null;
|
||||
/** An optional donation added on top (the register form's "Donation amount"). */
|
||||
donationAmount?: number | null;
|
||||
provider?: string | null;
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
failureUrl?: string;
|
||||
};
|
||||
|
||||
export type CheckoutResult =
|
||||
| { priceUpdated: true; newTotal: number; message: string }
|
||||
| { priceUpdated?: false; checkoutId: string; redirectUrl: string; provider: string; amount: number; registration: { id: string; eventId: string; eventTitle: string } };
|
||||
|
||||
/**
|
||||
* Starts an online payment for what's owed on a registration (the owner, or a supervisor or
|
||||
* admin). If an early-bird price ran out since it was made, nothing is charged: the caller is
|
||||
* told the new total to show first.
|
||||
*/
|
||||
export async function startRegistrationCheckout(registrationId: string, actor: { id: string; role: Role }, opts: CheckoutOptions = {}): Promise<CheckoutResult> {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true, eventId: true, event: { select: { endDate: true } } } });
|
||||
if (!reg) return fail(404, 'Registration not found');
|
||||
const staffSide = actor.role === 'admin' || actor.role === 'supervisor';
|
||||
if (reg.userId !== actor.id && !staffSide) fail(403, 'Not authorized to create checkout for this registration');
|
||||
// If event is in the past, block self-service payment (mirrors the registration-edit block)
|
||||
if (!staffSide && reg.event?.endDate && new Date(reg.event.endDate).getTime() < Date.now()) {
|
||||
fail(400, 'This event has already ended; payment can no longer be made for this registration');
|
||||
}
|
||||
try {
|
||||
await assertEventOpen(reg.eventId);
|
||||
} catch (e: any) {
|
||||
throw new ServiceError(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
|
||||
// Refresh early-bird pricing — updates priceSnapshot if any tier has expired or sold out.
|
||||
// Must run before the balance is worked out so the checkout charges the right price.
|
||||
const { changed } = await pricing.refreshPricingForRegistration(registrationId);
|
||||
if (changed) {
|
||||
const fresh = await prisma.registration.findUniqueOrThrow({
|
||||
where: { id: registrationId },
|
||||
include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true },
|
||||
});
|
||||
const newTotal = pricing.computeRegistrationTotalDue(fresh, new Date());
|
||||
const totalPaid = fresh.payments.reduce((s, p) => s + p.amount, 0);
|
||||
return {
|
||||
priceUpdated: true,
|
||||
newTotal: Math.max(0, newTotal - totalPaid),
|
||||
message: 'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.',
|
||||
};
|
||||
}
|
||||
|
||||
const urls = { successUrl: opts.successUrl, cancelUrl: opts.cancelUrl, failureUrl: opts.failureUrl, provider: opts.provider ?? null };
|
||||
|
||||
// Part of the balance (supervisor payments page)
|
||||
if (opts.amount !== undefined && opts.amount !== null && !isNaN(Number(opts.amount))) {
|
||||
return createRegistrationCheckout(registrationId, actor.id, { ...urls, amount: Number(opts.amount) });
|
||||
}
|
||||
|
||||
let donation = 0;
|
||||
if (opts.donationAmount !== undefined && opts.donationAmount !== null) {
|
||||
donation = Math.round(Number(opts.donationAmount) * 100) / 100;
|
||||
if (!(donation >= 0) || donation > 100000) fail(400, 'Invalid donation amount');
|
||||
if (donation > 0 && donation < payments.MIN_ONLINE_AMOUNT) fail(400, `Minimum donation is R${payments.MIN_ONLINE_AMOUNT}`);
|
||||
}
|
||||
return createRegistrationCheckout(registrationId, actor.id, { ...urls, donationAmount: donation });
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Making and cancelling registrations (1.51.1). Moved here from registrationController.js so
|
||||
* POST /api/registrations (v1) and POST /api/v2/registrations run the same code. Errors are
|
||||
* ServiceErrors carrying their HTTP status.
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils') as { generateTicketsForRegistration(id: string): Promise<unknown> };
|
||||
const pricing = require('../utils/pricing') as {
|
||||
resolveOptionPrice(option: any, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
resolveVariantTierPrice(option: any, variantId: string, qty: number, buyer: any): Promise<{ price: number; tierId: string | null }>;
|
||||
computeRegistrationTotalDue(registration: any, at: Date): number;
|
||||
attachComputedTotals<T>(registration: T): T & { totalDueComputed: number };
|
||||
};
|
||||
const coupons = require('../utils/coupons') as {
|
||||
buildCouponLines(registration: any): any[];
|
||||
previewCoupon(args: { code: string; eventId: string; user: any; lines: any[] }): Promise<unknown>;
|
||||
applyCouponToRegistration(args: { registrationId: string; code: string }): Promise<unknown>;
|
||||
recalculateRegistrationDiscount(registrationId: string): Promise<number>;
|
||||
releaseRegistrationCoupons(registrationId: string): Promise<unknown>;
|
||||
};
|
||||
const attendeesUtil = require('../utils/attendees') as {
|
||||
lineKey(eventOptionId: string, variantId?: string | null): string;
|
||||
validateAttendees(event: any, places: Record<string, number>, attendees: unknown, existing: Record<string, number>): any[];
|
||||
createAttendees(registrationId: string, attendees: any[]): Promise<unknown>;
|
||||
namedPlacesByKey(registrationId: string): Promise<Record<string, number>>;
|
||||
};
|
||||
const { findAccountByContact } = require('../utils/accountMatch') as { findAccountByContact(c: { email?: string; phone?: string }): Promise<{ user: { id: string } | null; conflict?: boolean }> };
|
||||
const { ensureConsentRequests } = require('../utils/consent') as { ensureConsentRequests(registrationId: string): Promise<unknown> };
|
||||
const { takenCount } = require('../utils/stock') as { takenCount(where: object, opts?: { excludeEntryId?: string | null }): Promise<number> };
|
||||
const waitlist = require('../utils/waitlist') as { findOfferByToken(token: string): Promise<any>; queueWaitlist(eventId: string): void };
|
||||
const { reservedOptionIds } = require('../utils/volunteers') as { reservedOptionIds(event: any): string[] };
|
||||
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles') as { BUNDLE_APPS_INCLUDE: any };
|
||||
const sessions = require('../utils/sessions') as {
|
||||
registrationClosesAt(event: any, sessions: any[]): Date;
|
||||
wholeCourseClosesAt(event: any, sessions: any[]): Date;
|
||||
expandPickLines(event: any, options: any[], opts: { now: Date }): any[];
|
||||
assertSessionCapacity(event: any, lines: any[], registrationId?: string, opts?: { excludeEntryId?: string }): Promise<void>;
|
||||
mergeSessionIds(a: string[] | null | undefined, b: string[] | null | undefined): string[];
|
||||
};
|
||||
const { assertEventOpen } = require('../utils/cashupUtils') as { assertEventOpen(eventId: string, res?: unknown): Promise<unknown> };
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
|
||||
export type Role = 'admin' | 'supervisor' | 'staff' | 'user';
|
||||
export type Actor = { id: string; role: Role; email?: string } | null;
|
||||
|
||||
export const TICKET_DELIVERY = ['payer', 'each'] as const;
|
||||
export const isStaffRole = (role: string | undefined | null): boolean => ['admin', 'supervisor', 'staff'].includes(String(role));
|
||||
|
||||
// Named attendees for API responses, with whether their ticket has been issued/used.
|
||||
export const ATTENDEES_INCLUDE = {
|
||||
orderBy: { order: 'asc' as const },
|
||||
include: {
|
||||
ticket: { select: { id: true, isUsed: true } },
|
||||
// Latest consent request (youth events) — it decides check-in.
|
||||
consentRequests: { orderBy: { createdAt: 'desc' as const }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
|
||||
},
|
||||
};
|
||||
|
||||
// Active coupon on a registration, for API responses (the discount itself is
|
||||
// Registration.discountTotal).
|
||||
export const ACTIVE_COUPONS_INCLUDE = {
|
||||
where: { releasedAt: null },
|
||||
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
|
||||
};
|
||||
|
||||
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
||||
// the background so a slow mail/WhatsApp send never holds up the registration.
|
||||
export function queueConsentRequests(registrationId: string): void {
|
||||
ensureConsentRequests(registrationId).catch((e: any) => console.error('[consent] Failed to send requests:', e?.message || e));
|
||||
}
|
||||
|
||||
type Line = { eventOptionId: string; variantId?: string | null; sessionId?: string | null; quantity: number | string };
|
||||
|
||||
/**
|
||||
* Places each line will have once `requested` is added to `existingLines` — used to
|
||||
* check attendee names against what's actually being bought.
|
||||
*/
|
||||
export function placesAfter(existingLines: Line[] | undefined, requested: Line[] | undefined): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
// Picked-session lines take no attendee names.
|
||||
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
|
||||
const k = attendeesUtil.lineKey(l.eventOptionId, l.variantId);
|
||||
out[k] = (out[k] || 0) + Number(l.quantity || 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
||||
* before anything is created. Mirrors buildCouponLines for one tranche per option.
|
||||
*/
|
||||
export function pendingCouponLines(event: { eventOptions: { id: string }[] }, resolvedOptions: any[]): any[] {
|
||||
return coupons.buildCouponLines({
|
||||
registrationOptions: resolvedOptions.map(o => ({
|
||||
eventOptionId: o.eventOptionId,
|
||||
variantId: o.variantId || null,
|
||||
quantity: o.quantity,
|
||||
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
|
||||
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
|
||||
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
|
||||
*/
|
||||
export async function priceRequestedOption(eventOption: any, variantId: string | null, qty: number, buyer: any): Promise<{ priceSnapshot: number; appliedTierId: string | null }> {
|
||||
try {
|
||||
const resolved = variantId
|
||||
? await pricing.resolveVariantTierPrice(eventOption, variantId, qty, buyer)
|
||||
: await pricing.resolveOptionPrice(eventOption, qty, buyer);
|
||||
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
|
||||
} catch {
|
||||
const variant = variantId ? (eventOption.variants || []).find((v: any) => v.id === variantId) : null;
|
||||
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
|
||||
return { priceSnapshot: Number(price || 0), appliedTierId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check overall stock availability for an EventOption. Places held for live waitlist
|
||||
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
||||
*/
|
||||
export async function checkOptionStock(option: { id: string; stockLimit: number }, requestedQty: number, excludeEntryId: string | null = null, sessionId: string | null = null) {
|
||||
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null as number | null };
|
||||
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
/** Check stock availability for a specific variant (same rules as checkOptionStock). */
|
||||
export async function checkVariantStock(variant: { id: string; eventOptionId: string; stockLimit: number | null }, requestedQty: number, excludeEntryId: string | null = null, sessionId: string | null = null) {
|
||||
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null as number | null };
|
||||
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
|
||||
return { available: remaining >= requestedQty, remaining };
|
||||
}
|
||||
|
||||
export type RequestedOption = { eventOptionId: string; quantity: number; variantId?: string | null; sessionIds?: string[] };
|
||||
export type CreateRegistrationInput = {
|
||||
eventId: string;
|
||||
options: RequestedOption[];
|
||||
guestName?: string;
|
||||
guestEmail?: string;
|
||||
guestPhone?: string;
|
||||
couponCode?: string;
|
||||
attendees?: unknown[];
|
||||
ticketDelivery?: string;
|
||||
waitlistToken?: string;
|
||||
analyticsSessionId?: string;
|
||||
};
|
||||
|
||||
const FULL_INCLUDE = {
|
||||
registrationOptions: { include: { eventOption: true, tranches: true } },
|
||||
event: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Registers someone for an event (online, a guest for a free event, or with a waitlist link):
|
||||
* checks the event is open, prices each line (early-bird, variant, staff price), checks stock
|
||||
* and session places, adds to an existing registration for the same person and event, applies
|
||||
* bundle deals and a coupon, and issues tickets straight away when nothing is owed.
|
||||
*/
|
||||
export async function createRegistration(input: CreateRegistrationInput, actor: Actor) {
|
||||
try {
|
||||
return await create(input, actor);
|
||||
} catch (e: any) {
|
||||
// The helpers (coupons, attendee names, session places…) throw plain Errors written for
|
||||
// people; like v1, those are the caller's problem (400) unless they carry a status.
|
||||
if (e instanceof ServiceError || /^P\d{4}$/.test(String(e?.code || ''))) throw e;
|
||||
throw new ServiceError(e?.statusCode || 400, e?.message || 'Registration failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function create(input: CreateRegistrationInput, actor: Actor) {
|
||||
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = input;
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } }, sessions: true },
|
||||
});
|
||||
if (!event) return fail(404, 'Event not found');
|
||||
if (!event.isActive) return fail(400, 'Event is not active');
|
||||
if (event.cashupStatus === 'closed') return fail(400, 'This event is closed and no longer accepting registrations.');
|
||||
|
||||
// A waitlist claim link: its token identifies the person and releases the places
|
||||
// held for them. It works without logging in, the same way a consent link does.
|
||||
let waitlistOffer: any = null;
|
||||
if (waitlistToken) {
|
||||
waitlistOffer = await waitlist.findOfferByToken(waitlistToken);
|
||||
if (!waitlistOffer || waitlistOffer.eventId !== eventId) fail(400, 'This waitlist link is not valid.');
|
||||
if (waitlistOffer.status !== 'offered') fail(400, `This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`);
|
||||
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) fail(400, 'This waitlist offer has expired.');
|
||||
if (actor && actor.id !== waitlistOffer.userId) fail(403, 'This waitlist offer belongs to someone else.');
|
||||
}
|
||||
|
||||
// Enforce auth unless event explicitly opts out
|
||||
if (event.requiresAuth !== false && !actor && !waitlistOffer) fail(401, 'Authentication required for this event');
|
||||
|
||||
// Unauthenticated guest registration is only permitted for free events
|
||||
if (!actor && !waitlistOffer) {
|
||||
const requestedTotal = (options || []).reduce((sum, opt) => {
|
||||
const eo = event.eventOptions.find(o => o.id === opt.eventOptionId);
|
||||
return sum + ((eo?.price || 0) * (opt.quantity || 1));
|
||||
}, 0);
|
||||
if (requestedTotal > 0) fail(400, 'Guest registration is only available for free events. Please create an account to pay for tickets.');
|
||||
}
|
||||
|
||||
// Resolve userId — authenticated or guest
|
||||
let userId: string;
|
||||
if (actor) {
|
||||
userId = actor.id;
|
||||
} else if (waitlistOffer) {
|
||||
userId = waitlistOffer.userId;
|
||||
} else {
|
||||
// Guest registration: find existing user by email or create inactive placeholder
|
||||
if (!guestEmail || !guestName) return fail(400, 'Name and email are required for unauthenticated registration');
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) fail(409, 'This email and phone number belong to two different accounts. Please log in, or check your details.');
|
||||
if (match.user) {
|
||||
userId = match.user.id;
|
||||
} else {
|
||||
const newGuest = await prisma.user.create({
|
||||
data: { id: uuidv4(), name: guestName, email: guestEmail, password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() },
|
||||
});
|
||||
userId = newGuest.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce registration cutoff for public registrations (multi-day events close when
|
||||
// the first session starts, or later with late joining — see utils/sessions.js).
|
||||
const now = new Date();
|
||||
if (now >= sessions.registrationClosesAt(event, event.sessions)) fail(400, 'Registration is closed for this event');
|
||||
|
||||
if (!options || !Array.isArray(options) || options.length === 0) fail(400, 'At least one option must be selected');
|
||||
|
||||
// "Pick sessions" options become one line per chosen session; whole-course tickets
|
||||
// on such an event still close when the whole course does.
|
||||
const lines = sessions.expandPickLines(event, options, { now });
|
||||
// (An all-sessions ticket with sessions ticked is checked per session instead.)
|
||||
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && now >= sessions.wholeCourseClosesAt(event, event.sessions)) {
|
||||
fail(400, 'Whole-course tickets are no longer on sale — choose the sessions you want instead.');
|
||||
}
|
||||
await sessions.assertSessionCapacity(event, lines, undefined, { excludeEntryId: waitlistOffer?.id });
|
||||
|
||||
// Validate each option and resolve prices / check stock. Options are independent of each
|
||||
// other, so resolve them concurrently. The kids check-in and volunteer tickets are handed
|
||||
// out by staff, never sold.
|
||||
const reserved = new Set(isStaffRole(actor?.role) ? [] : reservedOptionIds(event));
|
||||
const buyerFor = async () => actor || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
|
||||
const resolvedOptions = await Promise.all(lines.map(async (option: any) => {
|
||||
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
||||
if (!eventOption || reserved.has(eventOption.id)) fail(400, `Option with ID ${option.eventOptionId} not found for this event`);
|
||||
if (!option.quantity || option.quantity < 1) fail(400, 'Quantity must be at least 1');
|
||||
const qty = option.quantity;
|
||||
|
||||
// (A failing count doesn't block the sale — only a real shortage does.)
|
||||
const stockCheck = await checkOptionStock(eventOption!, qty, waitlistOffer?.id, option.sessionId).catch(() => ({ available: true, remaining: null }));
|
||||
if (!stockCheck.available) fail(400, `"${eventOption!.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
||||
|
||||
const variantId: string | null = option.variantId || null;
|
||||
if (variantId) {
|
||||
const variant = (eventOption!.variants || []).find(v => v.id === variantId);
|
||||
if (!variant) return fail(400, `Variant not found for option "${eventOption!.name}"`);
|
||||
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId).catch(() => ({ available: true, remaining: null }));
|
||||
if (!vStock.available) fail(400, `Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
||||
}
|
||||
|
||||
// Variant-level tiers take priority, then option-level tiers, then base prices, then
|
||||
// the staff price for Staff and above.
|
||||
const { priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, await buyerFor());
|
||||
return { ...option, variantId, appliedTierId, priceSnapshot: priceSnapshot ?? eventOption!.price };
|
||||
}));
|
||||
|
||||
// Check a coupon code before anything is saved, so a bad code doesn't leave a
|
||||
// half-made registration behind. It's applied (and re-checked under a lock) below.
|
||||
if (couponCode) {
|
||||
const buyer = actor ? { id: actor.id, role: actor.role, email: actor.email } : null;
|
||||
await coupons.previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
|
||||
}
|
||||
|
||||
// Check for existing non-cancelled registration for this user+event → merge instead
|
||||
const existingReg = await prisma.registration.findFirst({
|
||||
where: { userId, eventId, status: { not: 'cancelled' } },
|
||||
include: { registrationOptions: true },
|
||||
});
|
||||
|
||||
// Attendee names are checked against the places the registration will have after
|
||||
// this purchase, counting names already given on an existing registration.
|
||||
const cleanAttendees = attendeesUtil.validateAttendees(
|
||||
event,
|
||||
placesAfter(existingReg?.registrationOptions, resolvedOptions),
|
||||
attendees,
|
||||
existingReg ? await attendeesUtil.namedPlacesByKey(existingReg.id) : {},
|
||||
);
|
||||
if (ticketDelivery !== undefined && !(TICKET_DELIVERY as readonly string[]).includes(ticketDelivery)) fail(400, "ticketDelivery must be 'payer' or 'each'");
|
||||
|
||||
let registrationId: string;
|
||||
let isNewRegistration = false;
|
||||
if (existingReg) {
|
||||
// Merge: add a new price tranche per requested option into the existing registration.
|
||||
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
|
||||
// tickets bought at different early-bird prices into a single (wrong) price. Each
|
||||
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
|
||||
for (const opt of resolvedOptions) {
|
||||
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
|
||||
const existing = existingReg.registrationOptions.find(
|
||||
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null),
|
||||
);
|
||||
if (existing) {
|
||||
await prisma.$transaction([
|
||||
prisma.registrationOptionTranche.create({
|
||||
data: { id: uuidv4(), registrationOptionId: existing.id, quantity: opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null },
|
||||
}),
|
||||
prisma.registrationOption.update({
|
||||
where: { id: existing.id },
|
||||
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: sessions.mergeSessionIds(existing.sessionIds, opt.sessionIds) }) },
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
await prisma.registrationOption.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
registrationId: existingReg.id,
|
||||
eventOptionId: opt.eventOptionId,
|
||||
quantity: opt.quantity,
|
||||
variantId: opt.variantId || null,
|
||||
sessionId: opt.sessionId || null,
|
||||
sessionIds: opt.sessionIds || [],
|
||||
appliedTierId: opt.appliedTierId || null,
|
||||
priceSnapshot: opt.priceSnapshot,
|
||||
tranches: { create: [{ id: uuidv4(), quantity: opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Only downgrade from paid if the newly added items actually cost something
|
||||
const newItemsCost = resolvedOptions.reduce((sum, opt) => sum + ((opt.priceSnapshot || 0) * (opt.quantity || 0)), 0);
|
||||
await prisma.registration.update({
|
||||
where: { id: existingReg.id },
|
||||
data: existingReg.status === 'paid' && newItemsCost > 0 ? { status: 'partial_paid', updatedAt: new Date() } : { updatedAt: new Date() },
|
||||
});
|
||||
registrationId = existingReg.id;
|
||||
} else {
|
||||
isNewRegistration = true;
|
||||
registrationId = uuidv4();
|
||||
await prisma.registration.create({
|
||||
data: {
|
||||
id: registrationId,
|
||||
userId,
|
||||
eventId,
|
||||
updatedAt: new Date(),
|
||||
registrationOptions: {
|
||||
create: resolvedOptions.map(option => ({
|
||||
id: uuidv4(),
|
||||
eventOptionId: option.eventOptionId,
|
||||
quantity: option.quantity,
|
||||
variantId: option.variantId || null,
|
||||
sessionId: option.sessionId || null,
|
||||
sessionIds: option.sessionIds || [],
|
||||
appliedTierId: option.appliedTierId || null,
|
||||
priceSnapshot: option.priceSnapshot,
|
||||
tranches: { create: [{ id: uuidv4(), quantity: option.quantity, priceSnapshot: option.priceSnapshot, appliedTierId: option.appliedTierId || null }] },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (cleanAttendees.length > 0) {
|
||||
await attendeesUtil.createAttendees(registrationId, cleanAttendees);
|
||||
queueConsentRequests(registrationId);
|
||||
}
|
||||
if (waitlistOffer) {
|
||||
await prisma.waitlistEntry.update({
|
||||
where: { id: waitlistOffer.id },
|
||||
data: { status: 'claimed', claimedAt: new Date(), registrationId, offerTokenHash: null },
|
||||
});
|
||||
}
|
||||
if (ticketDelivery) {
|
||||
await prisma.registration.update({ where: { id: registrationId }, data: { ticketDelivery: ticketDelivery as 'payer' | 'each' } });
|
||||
}
|
||||
|
||||
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
|
||||
let couponError: string | null = null;
|
||||
await coupons.recalculateRegistrationDiscount(registrationId);
|
||||
if (couponCode) {
|
||||
try {
|
||||
await coupons.applyCouponToRegistration({ registrationId, code: couponCode });
|
||||
} catch (e: any) {
|
||||
// Lost a race for the last use since the check above — keep the registration.
|
||||
if (!e.isCouponError) throw e;
|
||||
couponError = e.message;
|
||||
}
|
||||
}
|
||||
const load = () => prisma.registration.findUniqueOrThrow({
|
||||
where: { id: registrationId },
|
||||
include: { ...FULL_INCLUDE, couponRedemptions: ACTIVE_COUPONS_INCLUDE, bundleApplications: BUNDLE_APPS_INCLUDE, attendees: ATTENDEES_INCLUDE },
|
||||
});
|
||||
let registration = await load();
|
||||
|
||||
// Nothing owed: mark paid and issue tickets (unless a required form comes first).
|
||||
let emailTicketsAfter = false;
|
||||
try {
|
||||
if (pricing.computeRegistrationTotalDue(registration, new Date()) === 0) {
|
||||
if (registration.status !== 'paid') {
|
||||
await prisma.registration.update({ where: { id: registrationId }, data: { status: 'paid', updatedAt: new Date() } });
|
||||
}
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } }).catch(() => null);
|
||||
if (!(form && form.isRequired)) {
|
||||
// Same generator as paid registrations: a ticket per named attendee, and one
|
||||
// shared quantity ticket for the rest of each line.
|
||||
await generateTicketsForRegistration(registrationId);
|
||||
emailTicketsAfter = true;
|
||||
}
|
||||
registration = { ...registration, status: 'paid' };
|
||||
}
|
||||
} catch {
|
||||
// ignore errors in auto-generation path; manual generation remains available
|
||||
}
|
||||
|
||||
// Fire-and-forget: registration email first, then tickets (guarantees order)
|
||||
const notifications = require('../utils/notifications') as { sendRegistrationEmails(id: string): Promise<unknown>; sendRegistrationUpdatedEmails(id: string): Promise<unknown> };
|
||||
const ownerId = registration.userId;
|
||||
void (async () => {
|
||||
try {
|
||||
if (isNewRegistration) await notifications.sendRegistrationEmails(registrationId);
|
||||
else await notifications.sendRegistrationUpdatedEmails(registrationId);
|
||||
} catch (e) { console.error('Failed to send registration emails:', e); }
|
||||
if (emailTicketsAfter) {
|
||||
try {
|
||||
const { emailTickets } = require('../controllers/ticketController') as { emailTickets(req: unknown, res: unknown): Promise<unknown> };
|
||||
const res: { status(): unknown; json(): void } = { status: () => res, json: () => {} };
|
||||
await emailTickets({ user: { id: ownerId }, body: { registrationId } }, res);
|
||||
} catch (e) { console.error('Error emailing tickets for free registration:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Funnel: this registration came from a tracked (cookie-consented) visit.
|
||||
if (analyticsSessionId) {
|
||||
const { recordSubmit } = require('../utils/analytics') as { recordSubmit(reg: unknown, sessionId: string): Promise<unknown> };
|
||||
recordSubmit(registration, analyticsSessionId).catch((e: any) => console.warn('[analytics] submit not recorded:', e?.message || e));
|
||||
}
|
||||
|
||||
return { registration: pricing.attachComputedTotals(registration), isNew: isNewRegistration, couponError };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a registration: the owner (not after the event, not once anything is paid) or an
|
||||
* admin. Releases its coupon and offers its places to the waitlist.
|
||||
*/
|
||||
export async function cancelRegistration(registrationId: string, actor: { id: string; role: Role }, ip?: string) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: { event: { select: { endDate: true } } },
|
||||
});
|
||||
if (!registration) return fail(404, 'Registration not found');
|
||||
|
||||
if (registration.userId !== actor.id && actor.role !== 'admin') fail(403, 'Not authorized to cancel this registration');
|
||||
|
||||
// If event is in the past, block self-cancellation (mirrors the edit block)
|
||||
if (actor.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
|
||||
fail(400, 'This event has already ended; registration can no longer be cancelled');
|
||||
}
|
||||
|
||||
try {
|
||||
await assertEventOpen(registration.eventId);
|
||||
} catch (e: any) {
|
||||
throw new ServiceError(e.message === 'Event not found' ? 404 : 400, e.message);
|
||||
}
|
||||
|
||||
if (registration.status === 'cancelled') fail(400, 'Registration is already cancelled');
|
||||
|
||||
// Non-admins cannot cancel a registration that has payments against it
|
||||
if (actor.role !== 'admin') {
|
||||
const paymentCount = await prisma.payment.count({ where: { registrationId: registration.id, amount: { gt: 0 } } });
|
||||
if (paymentCount > 0) fail(403, 'This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.');
|
||||
}
|
||||
|
||||
const updated = await prisma.registration.update({ where: { id: registrationId }, data: { status: 'cancelled', updatedAt: new Date() } });
|
||||
await coupons.releaseRegistrationCoupons(registrationId);
|
||||
waitlist.queueWaitlist(registration.eventId);
|
||||
|
||||
// Only log when staff cancels on someone else's behalf — a routine self-service
|
||||
// cancellation isn't an admin action worth cluttering the audit trail with.
|
||||
if (registration.userId !== actor.id) {
|
||||
logAdminAction({
|
||||
actorId: actor.id, actorRole: actor.role, action: 'registration_cancelled', targetType: 'Registration', targetId: registrationId,
|
||||
metadata: { registrationOwnerId: registration.userId }, ip,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
/**
|
||||
* v1 endpoints that have a v2 replacement say so (1.51.3): a `Deprecation` header (RFC 9745,
|
||||
* the date v2 covered them) and `Link: <…>; rel="successor-version"` pointing at the v2
|
||||
* endpoint. v1 keeps working until 2.0; endpoints without a v2 version get no header yet.
|
||||
*/
|
||||
|
||||
// When these v1 endpoints got their v2 replacement (1.51.3's release day).
|
||||
const DEPRECATED_AT = Math.floor(Date.UTC(2026, 9, 29) / 1000);
|
||||
|
||||
type Successor = { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string };
|
||||
|
||||
const SUCCESSORS: Successor[] = [
|
||||
{ method: 'GET', v1: /^\/api\/events\/?$/, v2: () => '/api/v2/events' },
|
||||
{ method: 'GET', v1: /^\/api\/events\/([^/]+)$/, v2: m => `/api/v2/events/${m[1]}` },
|
||||
{ method: 'GET', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
|
||||
{ method: 'PUT', v1: /^\/api\/users\/profile$/, v2: () => '/api/v2/me' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/?$/, v2: () => '/api/v2/auth/signup' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/login$/, v2: () => '/api/v2/auth/login' },
|
||||
{ method: 'POST', v1: /^\/api\/users\/login\/2fa$/, v2: () => '/api/v2/auth/login/code' },
|
||||
{ method: 'GET', v1: /^\/api\/registrations\/myregistrations$/, v2: () => '/api/v2/me/registrations' },
|
||||
{ method: 'POST', v1: /^\/api\/registrations\/?$/, v2: () => '/api/v2/registrations' },
|
||||
{ method: 'DELETE', v1: /^\/api\/registrations\/([^/]+)$/, v2: m => `/api/v2/registrations/${m[1]}/cancel` },
|
||||
{ method: 'POST', v1: /^\/api\/payments\/checkout$/, v2: () => '/api/v2/registrations/{id}/checkout' },
|
||||
{ method: 'GET', v1: /^\/api\/tickets\/scan-preview\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
|
||||
{ method: 'POST', v1: /^\/api\/tickets\/scan\/([^/]+)$/, v2: m => `/api/v2/checkin/${m[1]}` },
|
||||
];
|
||||
|
||||
// GET /api/events/all isn't a single event.
|
||||
const NOT_AN_EVENT_ID = new Set(['all']);
|
||||
|
||||
/** The v2 successor of a v1 request, if it has one. Pure; exported for tests. */
|
||||
export function successorOf(method: string, path: string): string | null {
|
||||
for (const s of SUCCESSORS) {
|
||||
if (s.method !== method) continue;
|
||||
const m = path.match(s.v1);
|
||||
if (!m) continue;
|
||||
if (s.v1.source.startsWith('^\\/api\\/events\\/(') && NOT_AN_EVENT_ID.has(m[1])) continue;
|
||||
return s.v2(m);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const deprecationHeaders: RequestHandler = (req, res, next) => {
|
||||
const successor = successorOf(req.method, req.originalUrl.split('?')[0]);
|
||||
if (successor) {
|
||||
res.setHeader('Deprecation', `@${DEPRECATED_AT}`);
|
||||
res.setHeader('Link', `<${successor}>; rel="successor-version"`);
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export const ERROR_STATUS: Record<ErrorCode, number> = {
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409,
|
||||
RATE_LIMITED: 429,
|
||||
READ_ONLY: 423,
|
||||
READ_ONLY: 403,
|
||||
INTERNAL: 500,
|
||||
};
|
||||
|
||||
@@ -80,3 +80,26 @@ export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
|
||||
};
|
||||
|
||||
export const notFoundHandler: RequestHandler = (_req, _res, next) => next(new ApiError('NOT_FOUND', 'There is no such API endpoint.'));
|
||||
|
||||
// v1-style codes from shared middleware, kept as the v2 error's `reason`.
|
||||
const READ_ONLY_REASONS = new Set(['IMPERSONATION_READ_ONLY', 'LICENCE_READ_ONLY']);
|
||||
|
||||
/**
|
||||
* Mounted in front of everything on /api/v2: middleware shared with v1 (rate limits, the
|
||||
* read-only guards, the auth checks, campus refusals) answers `{ message, code? }`; on v2 that
|
||||
* becomes `{ error: { code, message, reason? } }`, where `reason` is the specific old code
|
||||
* (e.g. TWO_FACTOR_REQUIRED, LICENCE_READ_ONLY, OTHER_CAMPUS).
|
||||
*/
|
||||
export const legacyErrorsToV2: RequestHandler = (_req, res, next) => {
|
||||
const json = res.json.bind(res);
|
||||
res.json = (body?: unknown) => {
|
||||
const b = body as { message?: unknown; code?: unknown; error?: unknown } | null;
|
||||
if (res.statusCode >= 400 && b && typeof b === 'object' && !('error' in b) && typeof b.message === 'string') {
|
||||
const reason = typeof b.code === 'string' ? b.code : undefined;
|
||||
const code: ErrorCode = reason && READ_ONLY_REASONS.has(reason) ? 'READ_ONLY' : STATUS_CODE[res.statusCode] || (res.statusCode >= 500 ? 'INTERNAL' : 'VALIDATION_FAILED');
|
||||
return json({ error: { code, message: b.message, ...(reason ? { reason } : {}) } });
|
||||
}
|
||||
return json(body);
|
||||
};
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const errorSchema = {
|
||||
code: { type: 'string', enum: Object.keys(ERROR_STATUS) },
|
||||
message: { type: 'string' },
|
||||
fields: { type: 'object', additionalProperties: { type: 'string' }, description: 'Per-field problems (VALIDATION_FAILED only)' },
|
||||
reason: { type: 'string', description: 'A more specific cause when there is one, e.g. TWO_FACTOR_REQUIRED, IMPERSONATION_ENDED, LICENCE_READ_ONLY, OTHER_CAMPUS' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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))) };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
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(),
|
||||
ticketOptionId: id.nullable().describe('For section checks'),
|
||||
ticketName: z.string().nullable(),
|
||||
variant: z.string().nullable(),
|
||||
attendee: z.string().nullable().describe('The attendee named on it, if any'),
|
||||
bookedBy: z.string().nullable().describe('Who registered'),
|
||||
quantity: z.number().int(),
|
||||
remaining: z.number().int().describe('Still to check in (this session, on multi-day events)'),
|
||||
session: sessionRef.nullable(),
|
||||
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,
|
||||
ticketOptionId: t.registrationOption.eventOption.id ?? null, variant: t.registrationOption.variant?.name ?? null,
|
||||
attendee: t.attendee?.name ?? null, bookedBy: t.user.name ?? null, quantity: t.quantity,
|
||||
remaining: t.remaining, session: t.session, sessionError: t.sessionError, consentStatus: t.consentStatus,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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 };
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 } };
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+219
-2
@@ -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,199 @@ 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({ attendee: 'Lulu', bookedBy: 'Thandi', ticketOptionId: 'o1', remaining: 2, consentStatus: 'not_required' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1 deprecation headers', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { successorOf } = require('../src/v2/deprecation');
|
||||
test('v1 endpoints with a v2 replacement point at it; the rest say nothing', () => {
|
||||
expect(successorOf('GET', '/api/events')).toBe('/api/v2/events');
|
||||
expect(successorOf('GET', '/api/events/e1')).toBe('/api/v2/events/e1');
|
||||
expect(successorOf('GET', '/api/events/all')).toBeNull();
|
||||
expect(successorOf('DELETE', '/api/registrations/r1')).toBe('/api/v2/registrations/r1/cancel');
|
||||
expect(successorOf('POST', '/api/tickets/scan/QR1')).toBe('/api/v2/checkin/QR1');
|
||||
expect(successorOf('GET', '/api/reports/summary')).toBeNull();
|
||||
expect(successorOf('GET', '/api/v2/events')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared middleware on v2', () => {
|
||||
test("v1-style refusals become v2 errors, keeping the specific code as reason", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { legacyErrorsToV2 } = require('../src/v2/errors');
|
||||
const mini = express();
|
||||
mini.use('/api/v2', legacyErrorsToV2);
|
||||
mini.get('/api/v2/limited', (_req, res) => { res.status(429).json({ message: 'Too many requests, please try again later.' }); });
|
||||
mini.post('/api/v2/locked', (_req, res) => { res.status(403).json({ code: 'LICENCE_READ_ONLY', message: 'Read only.' }); });
|
||||
mini.get('/api/v2/twofa', (_req, res) => { res.status(401).json({ code: 'TWO_FACTOR_REQUIRED', message: 'Set up 2FA.' }); });
|
||||
mini.get('/api/v2/fine', (_req, res) => { res.json({ data: { message: 'not an error' } }); });
|
||||
expect((await request(mini).get('/api/v2/limited')).body).toEqual({ error: { code: 'RATE_LIMITED', message: 'Too many requests, please try again later.' } });
|
||||
expect((await request(mini).post('/api/v2/locked')).body).toEqual({ error: { code: 'READ_ONLY', message: 'Read only.', reason: 'LICENCE_READ_ONLY' } });
|
||||
expect((await request(mini).get('/api/v2/twofa')).body.error).toMatchObject({ code: 'UNAUTHENTICATED', reason: 'TWO_FACTOR_REQUIRED' });
|
||||
expect((await request(mini).get('/api/v2/fine')).body).toEqual({ data: { message: 'not an error' } });
|
||||
});
|
||||
});
|
||||
|
||||
+26
-6
@@ -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.3** (and Hub 1.5.0). The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -14,6 +14,9 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
|
||||
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
|
||||
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
|
||||
| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` |
|
||||
| 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 +65,9 @@ encrypted settings unreadable or break existing installs.
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **1.51.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. **Decide how far v1 goes before 2.0** (roadmap "Still open"): v2 covers what members, guests and
|
||||
the scanner use; the staff/supervisor/admin screens still call ~350 v1 routes. Then **2.0.0 —
|
||||
AI helpers** (§15), AI endpoints on v2 only.
|
||||
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
|
||||
2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the
|
||||
licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub
|
||||
@@ -471,8 +474,25 @@ 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).
|
||||
- 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.
|
||||
- **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).
|
||||
- **Shared middleware on v2** (1.51.3): `legacyErrorsToV2` (mounted on `/api/v2` before everything)
|
||||
rewrites any `{ message, code }` error from middleware shared with v1 into the v2 shape, with the
|
||||
old code as `reason`. New middleware can keep writing `{ message, code }`.
|
||||
- **Deprecating v1:** when a v1 endpoint gets a v2 replacement, add it to `SUCCESSORS` in
|
||||
`src/v2/deprecation.ts`.
|
||||
- **Express 5:** route paths use path-to-regexp v8 (no `*`, `?` or regex in paths — use `{/:x}` for
|
||||
optional parts, `/*name` for wildcards); rejected promises in handlers reach the error handler.
|
||||
|
||||
## Event picker and webhook guide (1.50.2)
|
||||
|
||||
|
||||
+11
-3
@@ -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**, 1.51.2 **v2 accounts and check-in** and 1.51.3 **Express 5,
|
||||
scanner on v2, v1 deprecation headers** shipped. **Next: 2.0.0 (AI helpers) — see "Still open" on v1.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -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 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
@@ -1067,4 +1068,11 @@ feature.
|
||||
|
||||
## Still open
|
||||
|
||||
Nothing for now.
|
||||
- **How much of v1 goes before 2.0** (raised 2026-10-29, after 1.51.3). v2 now covers what members,
|
||||
guests and the scanner use (events, accounts, registering, paying, cancelling, check-in). The
|
||||
staff, supervisor and admin screens still call about 350 v1 routes (reports, cashup, messaging,
|
||||
settings, kids, volunteers…). Porting them all is several releases of work with little gain for
|
||||
churches. Options: (a) port everything, then remove v1 in 2.0 as planned; (b) **recommended** —
|
||||
2.0 removes only the v1 endpoints that have a v2 replacement, and the rest stays as an internal API
|
||||
(no deprecation header, not in the public docs) that moves to v2 when each screen is next
|
||||
worked on; (c) keep all of v1 through 2.0 and decide later.
|
||||
|
||||
@@ -35,6 +35,62 @@ 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("v1 endpoints with a v2 version say so", async ({ request }) => {
|
||||
const v1 = await request.get(`${API}/api/events`);
|
||||
expect(v1.headers()["deprecation"]).toMatch(/^@\d+$/);
|
||||
expect(v1.headers()["link"]).toBe(`</api/v2/events>; rel="successor-version"`);
|
||||
expect((await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } })).headers()["deprecation"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
|
||||
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
|
||||
|
||||
Generated
+243
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.3",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
@@ -54,6 +54,7 @@
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.5.24",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "3.4",
|
||||
"typescript": "^5"
|
||||
@@ -2770,6 +2771,99 @@
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/ajv": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js-replace": "^1.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/config": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
|
||||
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core": {
|
||||
"version": "1.34.22",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.22.tgz",
|
||||
"integrity": "sha512-dEyx4uHh6Pc6ZkdU9Xe8rA4h12Odq6QKU/fqseBn07xwVwskhHiF3KHhqmw5BR+jAiFTvKTmwCafFql/k1pSzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/ajv": "8.11.2",
|
||||
"@redocly/config": "0.22.0",
|
||||
"colorette": "1.4.0",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"js-levenshtein": "1.1.6",
|
||||
"js-yaml": "4.3.2",
|
||||
"minimatch": "5.1.9",
|
||||
"pluralize": "8.0.0",
|
||||
"yaml-ast-parser": "0.0.43"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0",
|
||||
"npm": ">=9.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-commonjs": {
|
||||
"version": "28.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz",
|
||||
@@ -4650,6 +4744,16 @@
|
||||
"ajv": "^8.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -5208,6 +5312,13 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/change-case": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -5316,6 +5427,13 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -6903,6 +7021,19 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/index-to-position": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -7446,6 +7577,16 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-levenshtein": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
|
||||
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -7498,8 +7639,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
@@ -8134,6 +8274,50 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
|
||||
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redocly/openapi-core": "^1.34.6",
|
||||
"ansi-colors": "^4.1.3",
|
||||
"change-case": "^5.4.4",
|
||||
"parse-json": "^8.3.0",
|
||||
"supports-color": "^10.2.2",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-typescript": "bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.x"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-typescript/node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -8223,6 +8407,37 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
|
||||
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"index-to-position": "^1.1.0",
|
||||
"type-fest": "^4.39.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json/node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -8300,6 +8515,16 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
@@ -8813,7 +9038,6 @@
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10051,6 +10275,13 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js-replace": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.2.tgz",
|
||||
"integrity": "sha512-VeBBt/nMxedczht3KDL2yBBMJqIxpCQLMZp3UJ1otcWwUHdQW4IiyHpJivExzSK+FdxTowRfGL4FmwDqH9+S8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
@@ -10328,6 +10559,13 @@
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml-ast-parser": {
|
||||
"version": "0.0.43",
|
||||
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.3",
|
||||
"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"
|
||||
|
||||
@@ -14,9 +14,10 @@ import type { Event, RecentScan, ScanStats, Section } from "@/types";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
|
||||
// GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline
|
||||
// scans build the same shape from the downloaded ticket list.
|
||||
// What the confirm step needs, from GET /api/v2/checkin/{qrCode}. Offline scans build the
|
||||
// same shape from the downloaded ticket list.
|
||||
type ScanTicket = {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
@@ -187,7 +188,21 @@ export default function TicketScanningPage() {
|
||||
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
|
||||
let preview: ScanTicket;
|
||||
try {
|
||||
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}${sessionParam ? `?sessionId=${encodeURIComponent(sessionParam)}` : ""}`, { authToken: token });
|
||||
const { data } = await apiV2("get", "/checkin/{qrCode}", { params: { qrCode: text }, query: sessionParam ? { sessionId: sessionParam } : {}, authToken: token });
|
||||
// A ticket that was handed on: the message says who has it now.
|
||||
if (data.outcome === "transferred" || !data.ticket) {
|
||||
setErrorModal(data.message || "This ticket has been transferred.");
|
||||
return;
|
||||
}
|
||||
const t = data.ticket;
|
||||
preview = {
|
||||
id: t.id, eventId: t.eventId, eventOptionId: t.ticketOptionId || undefined, quantity: t.quantity, consentStatus: t.consentStatus,
|
||||
event: { id: t.eventId, title: t.eventTitle || undefined },
|
||||
registrationOption: { eventOption: { id: t.ticketOptionId || undefined, name: t.ticketName || undefined }, variant: t.variant ? { name: t.variant } : null },
|
||||
attendee: t.attendee ? { name: t.attendee } : null,
|
||||
user: { name: t.bookedBy || undefined },
|
||||
remaining: t.remaining, session: t.session, sessionError: t.sessionError,
|
||||
};
|
||||
} catch (pe) {
|
||||
setErrorModal(errorMessage(pe) || 'Failed to validate ticket');
|
||||
return;
|
||||
@@ -280,38 +295,34 @@ export default function TicketScanningPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Background write — reverse the optimistic update on failure
|
||||
// Background write — reverse the optimistic update unless it checked in
|
||||
const undo = () => { setSuccessModal(null); setScanInfo(null); };
|
||||
try {
|
||||
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
|
||||
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
|
||||
}`;
|
||||
await apiFetch(url, {
|
||||
method: "POST",
|
||||
const { data } = await apiV2("post", "/checkin/{qrCode}", {
|
||||
params: { qrCode },
|
||||
authToken: token,
|
||||
body: {
|
||||
qty,
|
||||
people: qty,
|
||||
deviceId: offline.deviceId(),
|
||||
...(selectedEventId && selectedEventId !== "all" ? { eventId: selectedEventId } : {}),
|
||||
...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}),
|
||||
...(ticket?.session ? { sessionId: ticket.session.id } : {}),
|
||||
...(overrideConsent ? { overrideConsent: true } : {}),
|
||||
},
|
||||
});
|
||||
refreshStatsAndRecent();
|
||||
} catch (e) {
|
||||
setSuccessModal(null);
|
||||
setScanInfo(null);
|
||||
const raw = errorMessage(e) || "Failed to scan ticket";
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const msg: string | undefined = parsed?.message;
|
||||
if (msg && /already\s*(been)?\s*used/i.test(msg)) {
|
||||
setAlreadyUsedModal({ message: msg, ticket: parsed?.ticket });
|
||||
return;
|
||||
}
|
||||
setErrorModal(msg || raw);
|
||||
} catch {
|
||||
setErrorModal(raw);
|
||||
if (data.outcome === "checked_in") {
|
||||
refreshStatsAndRecent();
|
||||
} else if (data.outcome === "already_used") {
|
||||
// Someone else scanned it in the meantime.
|
||||
undo();
|
||||
setAlreadyUsedModal({ message: data.message, ticket });
|
||||
} else {
|
||||
undo();
|
||||
setErrorModal(data.message);
|
||||
}
|
||||
} catch (e) {
|
||||
undo();
|
||||
setErrorModal(errorMessage(e) || "Failed to scan ticket");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { apiV2 } from "@/lib/apiV2";
|
||||
|
||||
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
|
||||
|
||||
@@ -1084,12 +1085,13 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
|
||||
const qty = qtyFor(ticket);
|
||||
try {
|
||||
setSubmittingId(ticket.id);
|
||||
const res = await apiFetch<{ qtyRedeemed?: number; remaining?: number }>(
|
||||
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
|
||||
{ method: "POST", authToken: token, body: { qty } }
|
||||
);
|
||||
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
|
||||
(res?.remaining ?? 0) > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
|
||||
const { data: res } = await apiV2("post", "/checkin/{qrCode}", {
|
||||
params: { qrCode: ticket.qrCode }, authToken: token, body: { people: qty, eventId },
|
||||
});
|
||||
// Already used, consent missing, wrong session…: say why.
|
||||
if (res.outcome !== "checked_in") throw new Error(res.message);
|
||||
setInfo(`${res.checkedIn} checked in for ${registration.user?.name || "guest"}${
|
||||
(res.remaining ?? 0) > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
|
||||
}. Confirmation sent.`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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
Vendored
+1405
File diff suppressed because it is too large
Load Diff
+18
-9
@@ -74,8 +74,9 @@ export function resolveToApiOrigin(urlOrPath: string | null | undefined): string
|
||||
return API_BASE ? `${API_BASE}${raw}` : raw;
|
||||
}
|
||||
|
||||
// The parsed JSON body of a failed request — the backend always sends `message`, and
|
||||
// some routes add a machine-readable `code` or extra detail fields.
|
||||
// The parsed JSON body of a failed request — v1 sends `message` (some routes add a
|
||||
// machine-readable `code` or extra detail fields); for v2 it is the `error` object
|
||||
// (`code`, `message`, and `fields` on validation errors).
|
||||
export type ApiErrorData = { message?: string; code?: string; [key: string]: unknown };
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -122,6 +123,12 @@ 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?, reason? } }. `code` is the specific
|
||||
// reason when there is one (TWO_FACTOR_REQUIRED, OTHER_CAMPUS…), like v1; v2Code is v2's own.
|
||||
if (json?.error && typeof json.error === "object" && typeof json.error.message === "string") {
|
||||
message = json.error.message;
|
||||
data = { ...json.error, code: json.error.reason || json.error.code, v2Code: json.error.code };
|
||||
}
|
||||
} catch { /* not JSON — use raw text */ }
|
||||
// An admin/supervisor session from before 2FA was required for their role:
|
||||
// end it and send them to log in, where they enrol.
|
||||
@@ -198,21 +205,23 @@ export function fetchAllPayments(token: string, params: Record<string, string> =
|
||||
return fetchAllPages<Payment>("/api/payments", token, params);
|
||||
}
|
||||
|
||||
// Creates an online checkout (default payment provider) for a registration's full remaining balance.
|
||||
// Omitting `amount` makes the backend charge the full remainder rather than a partial amount —
|
||||
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
|
||||
// Starts an online payment for everything still owed on one of my registrations (API v2), with
|
||||
// an optional donation on top. Partial amounts are only taken from the supervisor payments page.
|
||||
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
|
||||
|
||||
export function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
|
||||
return apiFetch<CheckoutResponse>("/api/payments/checkout", {
|
||||
method: "POST",
|
||||
export async function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
|
||||
const { apiV2 } = await import("@/lib/apiV2");
|
||||
const { data } = await apiV2("post", "/registrations/{id}/checkout", {
|
||||
params: { id: registrationId },
|
||||
authToken: token,
|
||||
body: {
|
||||
registrationId,
|
||||
...(donationAmount && donationAmount > 0 ? { donationAmount } : {}),
|
||||
successUrl: window.location.origin + "/payment/success",
|
||||
cancelUrl: window.location.origin + "/payment/cancel",
|
||||
failureUrl: window.location.origin + "/payment/failure",
|
||||
},
|
||||
});
|
||||
// The price changed since registering: nothing was started — show the new amount owing.
|
||||
if (data.priceUpdated) return { priceUpdated: true, newTotal: data.newBalance, message: data.message };
|
||||
return { redirectUrl: data.redirectUrl };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { paths } from "@/lib/api-v2/schema";
|
||||
|
||||
/**
|
||||
* Typed calls to /api/v2. The types come from the backend's OpenAPI document
|
||||
* (`npm run api:types` in the repo root regenerates src/lib/api-v2/), so a path, its parameters,
|
||||
* its body and its answer are all checked by TypeScript:
|
||||
*
|
||||
* const { data, meta } = await apiV2("get", "/events", { query: { q: "camp", limit: 10 } });
|
||||
* const { data } = await apiV2("post", "/registrations/{id}/cancel", { params: { id }, authToken });
|
||||
*
|
||||
* Errors throw ApiError (from apiFetch) with the v2 `code` and `fields` in `.data`.
|
||||
*/
|
||||
|
||||
type Method = "get" | "post" | "put" | "patch" | "delete";
|
||||
type PathsWith<M extends Method> = { [P in keyof paths]: paths[P][M] extends { responses: unknown } ? P : never }[keyof paths];
|
||||
type Op<P extends keyof paths, M extends Method> = NonNullable<paths[P][M]>;
|
||||
|
||||
type Json<T> = T extends { content: { "application/json": infer J } } ? J : never;
|
||||
type Responses<O> = O extends { responses: infer R } ? R : never;
|
||||
/** The 2xx answer's JSON. */
|
||||
export type V2Result<P extends keyof paths, M extends Method> = Json<Responses<Op<P, M>>[Extract<keyof Responses<Op<P, M>>, 200 | 201>]>;
|
||||
|
||||
type PathParams<O> = O extends { parameters: { path: infer X } } ? (X extends Record<string, unknown> ? X : never) : never;
|
||||
type QueryParams<O> = O extends { parameters: { query?: infer X } } ? X : never;
|
||||
type Body<O> = O extends { requestBody: { content: { "application/json": infer B } } } ? B : never;
|
||||
|
||||
type IfAny<T, Then> = [T] extends [never] ? object : Then;
|
||||
export type V2Options<P extends keyof paths, M extends Method> =
|
||||
{ authToken?: string | null }
|
||||
& IfAny<PathParams<Op<P, M>>, { params: PathParams<Op<P, M>> }>
|
||||
& IfAny<QueryParams<Op<P, M>>, { query?: QueryParams<Op<P, M>> }>
|
||||
& IfAny<Body<Op<P, M>>, { body: Body<Op<P, M>> }>;
|
||||
|
||||
export function apiV2<M extends Method, P extends PathsWith<M>>(method: M, path: P, ...[options]: object extends V2Options<P, M> ? [V2Options<P, M>?] : [V2Options<P, M>]): Promise<V2Result<P, M>> {
|
||||
const opts = (options || {}) as { authToken?: string | null; params?: Record<string, string>; query?: Record<string, unknown>; body?: unknown };
|
||||
let url = String(path).replace(/\{(\w+)\}/g, (_, name: string) => encodeURIComponent(String(opts.params?.[name] ?? "")));
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query || {})) {
|
||||
if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
|
||||
}
|
||||
if (qs.toString()) url += `?${qs.toString()}`;
|
||||
return apiFetch<V2Result<P, M>>(`/api/v2${url}`, { method: method.toUpperCase(), authToken: opts.authToken, body: opts.body });
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.3",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.51.0",
|
||||
"version": "1.51.3",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
@@ -16,7 +16,8 @@
|
||||
"demo:small": "npm --prefix backend run set-demo -- --profile=small",
|
||||
"demo:events": "npm --prefix backend run set-demo -- --profile=events",
|
||||
"demo:ministry": "npm --prefix backend run set-demo -- --profile=ministry",
|
||||
"demo:multicampus": "npm --prefix backend run set-demo -- --profile=multicampus"
|
||||
"demo:multicampus": "npm --prefix backend run set-demo -- --profile=multicampus",
|
||||
"api:types": "npm --prefix backend run openapi && npm --prefix frontend run api:types"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
Reference in New Issue
Block a user