Compare commits

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

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