Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce9734090c | ||
|
|
9012ec64b6 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,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": {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
// Scanning, shared with /api/v2/checkin (TypeScript, 1.51.2).
|
||||
const checkin = require('../services/checkin');
|
||||
|
||||
// @desc Generate tickets for a registration
|
||||
// @route POST /api/tickets/generate
|
||||
@@ -256,99 +257,11 @@ const getTicketByQrCode = async (req, res) => {
|
||||
// @access Private/Staff
|
||||
const getScanPreview = async (req, res) => {
|
||||
try {
|
||||
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
|
||||
const rows = await prisma.$queryRaw`
|
||||
SELECT
|
||||
t.id,
|
||||
t."qrCode",
|
||||
t.quantity,
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
ro.id AS "registrationOptionId",
|
||||
ro."sessionId" AS "pinnedSessionId",
|
||||
ro."sessionIds" AS "chosenSessionIds",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
ov.id AS "variantId",
|
||||
ov.name AS "variantName",
|
||||
e.id AS "evId",
|
||||
e.title AS "eventTitle",
|
||||
e."scheduleType" AS "scheduleType",
|
||||
u.id AS "userId",
|
||||
u.name AS "userName",
|
||||
a.name AS "attendeeName",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'quantityRedeemed', tu."quantityRedeemed",
|
||||
'scannedAt', tu."scannedAt",
|
||||
'sessionId', tu."sessionId"
|
||||
)
|
||||
) FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "User" u ON u.id = t."userId"
|
||||
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
|
||||
const transfer = await findTransferByOldCode(req.params.qrCode);
|
||||
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
const { consentStatusForTickets } = require('../utils/consent');
|
||||
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
|
||||
const usages = r.usages || [];
|
||||
// Multi-day events: check-in is per session, so what's left is counted for the
|
||||
// session this scan is for (the one picked, or today's).
|
||||
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
let session = null;
|
||||
let sessionError = null;
|
||||
if (r.scheduleType === 'sessions') {
|
||||
const sessionsUtil = require('../utils/sessions');
|
||||
const schedule = await sessionsUtil.eventSchedule(r.eventId);
|
||||
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null, new Date(), r.chosenSessionIds);
|
||||
sessionError = found.error;
|
||||
if (found.session) {
|
||||
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
|
||||
remaining = Number(r.quantity) - sessionsUtil.redeemedForSession(usages, found.session.id);
|
||||
} else {
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
consentStatus,
|
||||
remaining,
|
||||
session,
|
||||
sessionError,
|
||||
registrationOption: {
|
||||
id: r.registrationOptionId,
|
||||
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
|
||||
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
|
||||
},
|
||||
event: { id: r.evId, title: r.eventTitle },
|
||||
user: { id: r.userId, name: r.userName },
|
||||
attendee: r.attendeeName ? { name: r.attendeeName } : null,
|
||||
usages
|
||||
});
|
||||
const result = await checkin.previewScan(req.params.qrCode, { sessionId: req.query.sessionId || null });
|
||||
if (result.outcome === 'transferred') return res.status(410).json({ message: result.message, code: 'TICKET_TRANSFERRED' });
|
||||
res.json(result.ticket);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -357,187 +270,19 @@ const getScanPreview = async (req, res) => {
|
||||
// @access Private/Staff
|
||||
const scanTicket = async (req, res) => {
|
||||
try {
|
||||
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
|
||||
const rows = await prisma.$queryRaw`
|
||||
SELECT
|
||||
t.id,
|
||||
t.quantity,
|
||||
t."isUsed",
|
||||
t."eventId",
|
||||
e.title AS "eventTitle",
|
||||
e."scheduleType" AS "scheduleType",
|
||||
ro."sessionId" AS "pinnedSessionId",
|
||||
ro."sessionIds" AS "chosenSessionIds",
|
||||
eo.id AS "eventOptionId",
|
||||
eo.name AS "eventOptionName",
|
||||
eo."isMainTicket" AS "isMainTicket",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', tu.id,
|
||||
'quantityRedeemed', tu."quantityRedeemed",
|
||||
'scannedAt', tu."scannedAt",
|
||||
'sessionId', tu."sessionId"
|
||||
)
|
||||
) FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, e.id, eo.id, ro.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
|
||||
const transfer = await findTransferByOldCode(req.params.qrCode);
|
||||
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
const ticket = {
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
event: { title: r.eventTitle },
|
||||
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
|
||||
usages: r.usages || []
|
||||
};
|
||||
|
||||
// Optional server-side guard: ensure ticket belongs to the requested event when provided
|
||||
const providedEventId = String((req.query?.eventId || req.body?.eventId) || '').trim();
|
||||
if (providedEventId && ticket.eventId !== providedEventId) {
|
||||
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
|
||||
}
|
||||
|
||||
// Section checks used to happen only in the browser; enforce them here too.
|
||||
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
|
||||
if (sectionId) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
|
||||
if (!section || section.eventId !== ticket.eventId) {
|
||||
return res.status(400).json({ message: 'Section not found for this event', ticket });
|
||||
}
|
||||
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
|
||||
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
|
||||
}
|
||||
}
|
||||
|
||||
// A section (or the whole event) locked to another device for offline scanning
|
||||
// can't be scanned online — those scans would be invisible to the offline device.
|
||||
const { blockingLock } = require('../utils/scanLocks');
|
||||
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
|
||||
if (lock) {
|
||||
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
|
||||
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
|
||||
}
|
||||
|
||||
// Multi-day events: the check-in is for one session (the one picked, or today's), and
|
||||
// what's left is counted per session.
|
||||
let session = null;
|
||||
let sessionsUtil = null;
|
||||
let schedule = null;
|
||||
if (r.scheduleType === 'sessions') {
|
||||
sessionsUtil = require('../utils/sessions');
|
||||
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
|
||||
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null, new Date(), r.chosenSessionIds);
|
||||
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
|
||||
session = found.session;
|
||||
}
|
||||
|
||||
// Compute how many have already been redeemed
|
||||
const totalRedeemed = session
|
||||
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
|
||||
: (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (session) {
|
||||
const last = (ticket.usages || []).filter(u => u.sessionId === session.id).sort((a, b) => new Date(b.scannedAt) - new Date(a.scannedAt))[0];
|
||||
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
|
||||
return res.status(403).json({
|
||||
message: `Already checked in for ${sessionsUtil.sessionLabel(session, schedule.sessions)}${at ? ` at ${at}` : ''}`,
|
||||
ticket,
|
||||
remaining: 0
|
||||
});
|
||||
}
|
||||
return res.status(403).json({
|
||||
message: 'Ticket has already been fully used',
|
||||
ticket,
|
||||
remaining: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Youth events: no check-in without parental consent, unless a supervisor overrides.
|
||||
const { consentStatusForTickets } = require('../utils/consent');
|
||||
const consentStatus = (await consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
|
||||
if (!['not_required', 'given'].includes(consentStatus)) {
|
||||
const canOverride = ['admin', 'supervisor'].includes(req.user?.role);
|
||||
if (!(req.body?.overrideConsent === true && canOverride)) {
|
||||
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
|
||||
return res.status(403).json({ message: `${why} — this ticket can't be checked in.`, code: 'CONSENT_REQUIRED', consentStatus, canOverride, ticket });
|
||||
}
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'consent_override',
|
||||
targetType: 'Ticket', targetId: ticket.id,
|
||||
metadata: { consentStatus, eventId: ticket.eventId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
// Determine how many to redeem this scan (defaults to all remaining)
|
||||
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
|
||||
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
|
||||
|
||||
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
|
||||
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
|
||||
const fullyUsed = newRemaining <= 0;
|
||||
|
||||
// Run the usage insert and ticket status update in parallel — they don't depend on each other
|
||||
const [ticketUsage] = await Promise.all([
|
||||
prisma.ticketUsage.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
ticketId: ticket.id,
|
||||
scannedById: req.user.id,
|
||||
quantityRedeemed: qtyToRedeem,
|
||||
sessionId: session ? session.id : null,
|
||||
}
|
||||
}),
|
||||
// A multi-session ticket is never "used up" — it's checked in again next session.
|
||||
prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { isUsed: session ? false : fullyUsed, updatedAt: new Date() }
|
||||
})
|
||||
]);
|
||||
|
||||
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
|
||||
// the QR camera flow are unaffected)
|
||||
if (ticket.registrationOption?.eventOption?.isMainTicket) {
|
||||
require('../utils/notifications')
|
||||
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
|
||||
.catch(e => console.error('Failed to send check-in notification:', e));
|
||||
}
|
||||
|
||||
const sessionPart = session ? ` for ${sessionsUtil.sessionLabel(session, schedule.sessions)}` : '';
|
||||
res.json({
|
||||
message: `Ticket scanned successfully${sessionPart} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
||||
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
|
||||
ticketUsage,
|
||||
ticket,
|
||||
qtyRedeemed: qtyToRedeem,
|
||||
totalRedeemed: newTotalRedeemed,
|
||||
remaining: newRemaining,
|
||||
});
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const result = await checkin.scanTicket(req.params.qrCode, req.user, {
|
||||
eventId: req.query?.eventId || req.body?.eventId || null,
|
||||
sectionId: req.query?.sectionId || req.body?.sectionId || null,
|
||||
sessionId: req.body?.sessionId || req.query?.sessionId || null,
|
||||
deviceId: req.body?.deviceId || null,
|
||||
qty: parseInt(req.body?.qty || req.body?.quantity) || null,
|
||||
overrideConsent: req.body?.overrideConsent === true,
|
||||
}, getClientIp(req));
|
||||
const { status, body } = checkin.v1ScanResponse(result);
|
||||
res.status(status).json(body);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,28 +3,22 @@ const { generateToken, hashPassword, comparePassword } = require('../config/auth
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const {
|
||||
METHODS: TWO_FACTOR_METHODS,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
} = require('../utils/twoFactor');
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Org name for email subjects — falls back to Cross Code (the platform vendor)
|
||||
// rather than any specific customer's name when unconfigured.
|
||||
function getOrgName() {
|
||||
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
}
|
||||
// Sign up, log in and profile changes are shared with /api/v2 (TypeScript, 1.51.2).
|
||||
const accounts = require('../services/auth');
|
||||
const { getOrgName, sendActivationLink, sendWelcomeEmail } = accounts;
|
||||
const { v1StatusOf } = require('../services/errors');
|
||||
|
||||
// Resolve a client IP from the request (works behind proxies)
|
||||
function getClientIp(req) {
|
||||
@@ -33,145 +27,10 @@ function getClientIp(req) {
|
||||
return req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
||||
|
||||
// Fire-and-forget: create a 24h activation token and deliver it to an inactive
|
||||
// account — via email if it has a real (non-guest) address, otherwise via
|
||||
// WhatsApp if it has a phone number. Used both when a login attempt hits an
|
||||
// inactive account, and immediately when an admin/supervisor creates an
|
||||
// account on someone's behalf (walk-in / manual registration).
|
||||
async function sendActivationLink(user) {
|
||||
const hasRealEmail = !!(user?.email && !user.email.endsWith('@guest.local'));
|
||||
if (!hasRealEmail && !user?.phoneNumber) return;
|
||||
try {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false },
|
||||
data: { used: true }
|
||||
});
|
||||
await prisma.passwordReset.create({
|
||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
||||
});
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
|
||||
if (hasRealEmail) {
|
||||
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
||||
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
||||
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
|
||||
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
|
||||
} else {
|
||||
const orgName = getOrgName();
|
||||
const waMessage = [
|
||||
`🔓 *Activate your ${orgName} account*`,
|
||||
'',
|
||||
`Hi ${user.name || 'there'},`,
|
||||
'',
|
||||
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
|
||||
'',
|
||||
activationUrl,
|
||||
'',
|
||||
`_This link expires in 24 hours._`,
|
||||
].join('\n');
|
||||
const { waTextAny } = require('../utils/notify');
|
||||
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: send a login notification email with approximate geo location
|
||||
async function sendLoginNotification(user, req) {
|
||||
try {
|
||||
const ip = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || 'Unknown device';
|
||||
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
|
||||
let location = 'Unknown location';
|
||||
if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) {
|
||||
try {
|
||||
const geo = await axios.get(
|
||||
`http://ip-api.com/json/${ip}?fields=status,city,regionName,country`,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
if (geo.data?.status === 'success') {
|
||||
const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean);
|
||||
if (parts.length) location = parts.join(', ');
|
||||
}
|
||||
} catch { /* geo lookup failure is non-fatal */ }
|
||||
}
|
||||
|
||||
const { sendMail, buildLoginNotificationEmail } = require('../utils/email');
|
||||
const { buildWALogin } = require('../utils/waMessages');
|
||||
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
|
||||
// Security: always email; also WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
|
||||
const { waText } = require('../utils/notify');
|
||||
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
|
||||
} catch (e) {
|
||||
console.warn('[login notification] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: send welcome email with next upcoming events
|
||||
async function sendWelcomeEmail(user) {
|
||||
try {
|
||||
const events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gt: new Date() } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 3,
|
||||
select: { title: true, startDate: true },
|
||||
});
|
||||
const { sendMail, buildWelcomeEmail } = require('../utils/email');
|
||||
const { buildWAWelcome } = require('../utils/waMessages');
|
||||
const content = buildWelcomeEmail({ name: user.name, events });
|
||||
const { shouldEmail, waText } = require('../utils/notify');
|
||||
// Welcome is always sent via email; also via WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
|
||||
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
|
||||
} catch (e) {
|
||||
console.warn('[welcome email] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fields the login flow needs once the password (and any 2FA code) has checked out.
|
||||
const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
|
||||
noticeVersionAccepted: true,
|
||||
};
|
||||
|
||||
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
|
||||
// the controller convention of res.status(...) before throwing.
|
||||
// Errors from utils/twoFactor and the services carry their own HTTP status; everything else
|
||||
// follows the controller convention of res.status(...) before throwing.
|
||||
function errorStatus(error, res) {
|
||||
return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
|
||||
}
|
||||
|
||||
// Final step of a successful login (after the password, and the 2FA code when
|
||||
// required): notify, record the activity, and issue the session token.
|
||||
async function completeLogin(user, req, res, extra = {}) {
|
||||
const noticePromptPending = await require('../utils/privacyNotice').noticePromptPending(user).catch(() => false);
|
||||
// Retention: logging in keeps the account (and cancels an inactivity warning).
|
||||
prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), inactivityWarnedAt: null } }).catch(() => {});
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(user, req).catch(() => {});
|
||||
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion),
|
||||
// POPIA: ask about News & announcements once (see NewsPrompt on the frontend), and to
|
||||
// accept a privacy notice version marked as a significant change (NoticePrompt).
|
||||
newsPromptPending: !user.newsAskedAt,
|
||||
noticePromptPending,
|
||||
...extra
|
||||
});
|
||||
return v1StatusOf(error, res.statusCode === 200 ? 400 : res.statusCode);
|
||||
}
|
||||
|
||||
// @desc Register a new user
|
||||
@@ -179,79 +38,9 @@ async function completeLogin(user, req, res, extra = {}) {
|
||||
// @access Public
|
||||
const registerUser = async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = req.body;
|
||||
|
||||
// Normalize phone using SA-aware normalisation
|
||||
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
|
||||
const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null);
|
||||
|
||||
// Validate preference — WhatsApp requires a valid phone number
|
||||
const allowedPrefs = ['email', 'whatsapp', 'both'];
|
||||
let pref = allowedPrefs.includes(notificationPreference) ? notificationPreference : 'email';
|
||||
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) {
|
||||
pref = 'email'; // silently fall back if no valid number
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const userExists = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email },
|
||||
...(phone ? [{ phoneNumber: phone }] : [])
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (userExists) {
|
||||
res.status(400);
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
phoneNumber: phone,
|
||||
notificationPreference: pref,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// POPIA: the sign-up form's (unticked) "news and announcements" box. Recorded either
|
||||
// way, so they aren't asked again at their first login.
|
||||
await require('../utils/privacy').setNewsPreferences(
|
||||
user.id,
|
||||
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
|
||||
{ source: 'signup', ip: getClientIp(req), answered: true, logAll: true },
|
||||
).catch(e => console.warn('[signup] news preference not saved:', e?.message || e));
|
||||
// The sign-up form requires ticking "I agree to the Terms of Use and Privacy Policy".
|
||||
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'signup', ip: getClientIp(req) })
|
||||
.catch(e => console.warn('[signup] notice acceptance not saved:', e?.message || e));
|
||||
|
||||
|
||||
// Send welcome email in the background — don't block the response
|
||||
sendWelcomeEmail(user).catch(() => {});
|
||||
|
||||
res.status(201).json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion)
|
||||
});
|
||||
} else {
|
||||
res.status(400);
|
||||
throw new Error('Invalid user data');
|
||||
}
|
||||
res.status(201).json(await accounts.signUp(req.body || {}, { ip: getClientIp(req), userAgent: req.headers['user-agent'] }));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -260,108 +49,12 @@ const registerUser = async (req, res) => {
|
||||
// @access Public
|
||||
const loginUser = async (req, res) => {
|
||||
try {
|
||||
const {email, password, deviceToken} = req.body;
|
||||
|
||||
const rawInput = email;
|
||||
|
||||
// Normalize phone input — convert SA local format (0xx) → international (27xx)
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
const normalizedPhone = normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
|
||||
|
||||
// Check for user email or phone
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: rawInput },
|
||||
...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : []),
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(401);
|
||||
throw new Error('User does not exist');
|
||||
const { email, password, deviceToken } = req.body || {};
|
||||
const result = await accounts.logIn({ identifier: email, password, deviceToken }, { ip: getClientIp(req), userAgent: req.headers['user-agent'] });
|
||||
if (result.status === 'code_required') {
|
||||
return res.json({ twoFactorRequired: true, ...(result.enrolling ? { enrolling: true } : {}), ...result.challenge });
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.isActive) {
|
||||
// Resend the activation link on each failed login attempt against an inactive
|
||||
// account, in case the original one (sent at creation, or a prior attempt) expired.
|
||||
await sendActivationLink(user);
|
||||
// If the account has a real email (not a guest placeholder), it went out via email
|
||||
if (user.email && !user.email.endsWith('@guest.local')) {
|
||||
res.status(401);
|
||||
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
|
||||
}
|
||||
// No real email — if they have a phone number, it went out via WhatsApp
|
||||
if (user.phoneNumber) {
|
||||
res.status(401);
|
||||
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
|
||||
}
|
||||
res.status(401);
|
||||
throw new Error('Your account has been deactivated');
|
||||
}
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCK_TIME = 5 * 60 * 1000; // 5 min
|
||||
|
||||
// Check lock
|
||||
if (user.lockUntil && user.lockUntil > new Date()) {
|
||||
const now = new Date().getTime();
|
||||
const lockTime = new Date(user.lockUntil).getTime();
|
||||
|
||||
const diffMs = lockTime - now;
|
||||
const diffMinutes = Math.ceil(diffMs / (1000 * 60));
|
||||
|
||||
throw new Error(`Too many attempts. Try again in ${diffMinutes} minute(s).`);
|
||||
}
|
||||
|
||||
// Check password
|
||||
const isMatch = await comparePassword(password, user.password);
|
||||
|
||||
if (!isMatch) {
|
||||
const attempts = user.failedAttempts + 1;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedAttempts: attempts,
|
||||
lockUntil:
|
||||
attempts >= MAX_ATTEMPTS
|
||||
? new Date(Date.now() + LOCK_TIME)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error("Invalid email or password");
|
||||
}
|
||||
|
||||
// ✅ SUCCESS → reset attempts
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedAttempts: 0, lockUntil: null },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
|
||||
// Two-factor: the password alone isn't enough unless this browser was
|
||||
// remembered earlier. Send a code and hand back a challenge instead of a token.
|
||||
if (updated.twoFactorEnabled && !(await isTrustedDevice(updated.id, deviceToken))) {
|
||||
const challenge = await createChallenge(updated, 'login', updated.twoFactorMethod || 'email');
|
||||
return res.json({ twoFactorRequired: true, ...challenge });
|
||||
}
|
||||
|
||||
// Admins and supervisors must use 2FA. Not set up yet: send a code to the
|
||||
// first channel that can deliver one, and verifying it turns 2FA on. A
|
||||
// trusted device can't skip this — nothing has been verified yet.
|
||||
if (!updated.twoFactorEnabled && requiresTwoFactor(updated)) {
|
||||
const method = await enforcementMethod(updated);
|
||||
if (method) {
|
||||
const challenge = await createChallenge(updated, 'login', method);
|
||||
return res.json({ twoFactorRequired: true, enrolling: true, ...challenge });
|
||||
}
|
||||
}
|
||||
|
||||
await completeLogin(updated, req, res);
|
||||
res.json(result.session);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -373,30 +66,7 @@ const loginUser = async (req, res) => {
|
||||
const verifyLoginTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId, code, rememberDevice } = req.body || {};
|
||||
const challenge = await verifyChallenge(challengeId, code, 'login');
|
||||
|
||||
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401);
|
||||
throw new Error('Your account has been deactivated');
|
||||
}
|
||||
|
||||
// First login since 2FA became required for this role: the code proves the
|
||||
// channel works, so turn 2FA on with it.
|
||||
if (!user.twoFactorEnabled && requiresTwoFactor(user)) {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
const extra = {};
|
||||
if (rememberDevice === true) {
|
||||
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
|
||||
}
|
||||
await completeLogin(user, req, res, extra);
|
||||
res.json(await accounts.verifyLoginCode({ challengeId, code, rememberDevice }, { ip: getClientIp(req), userAgent: req.headers['user-agent'] }));
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -565,114 +235,10 @@ const getUserProfile = async (req, res) => {
|
||||
// @access Private
|
||||
const updateUserProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = req.body || {};
|
||||
|
||||
// Home campus (only while campuses are on): an active campus, or ''/null for none.
|
||||
let newHomeCampusId = user.homeCampusId;
|
||||
if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) {
|
||||
if (!homeCampusId) newHomeCampusId = null;
|
||||
else {
|
||||
const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } });
|
||||
if (!campus) { res.status(400); throw new Error('Choose one of the campuses in the list.'); }
|
||||
newHomeCampusId = campus.id;
|
||||
}
|
||||
}
|
||||
|
||||
// If a password change is requested, verify current password for safety
|
||||
let newHashedPassword = undefined;
|
||||
if (typeof password === 'string' && password.trim().length > 0) {
|
||||
const newPw = password.trim();
|
||||
if (!currentPassword || typeof currentPassword !== 'string' || currentPassword.length === 0) {
|
||||
res.status(400);
|
||||
throw new Error('Current password is required to set a new password');
|
||||
}
|
||||
const matches = await comparePassword(currentPassword, user.password);
|
||||
if (!matches) {
|
||||
res.status(400);
|
||||
throw new Error('Current password is incorrect');
|
||||
}
|
||||
if (newPw.length < 8) {
|
||||
res.status(400);
|
||||
throw new Error('New password must be at least 8 characters long');
|
||||
}
|
||||
newHashedPassword = await hashPassword(newPw);
|
||||
}
|
||||
|
||||
// Normalize phone
|
||||
const { normalizeZAPhone } = require('../utils/whatsapp');
|
||||
let newPhone = user.phoneNumber;
|
||||
if (phoneNumber !== undefined) {
|
||||
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
|
||||
}
|
||||
|
||||
// Validate notification preference — WhatsApp requires a valid SA phone number
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
|
||||
|
||||
// 2FA codes can't go to WhatsApp without a usable number — move them to email
|
||||
// rather than leave the account pointing at a channel that no longer exists.
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
|
||||
|
||||
// Update user data
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
name: name || user.name,
|
||||
email: email || user.email,
|
||||
password: newHashedPassword ? newHashedPassword : user.password,
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
twoFactorMethod: newTwoFactorMethod,
|
||||
homeCampusId: newHomeCampusId,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
homeCampusId: true,
|
||||
tokenVersion: true
|
||||
}
|
||||
});
|
||||
|
||||
// If the password was changed, send a security alert email (fire-and-forget)
|
||||
if (newHashedPassword) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
|
||||
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
|
||||
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
|
||||
sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content })
|
||||
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(updatedUser, content.text).catch(() => {});
|
||||
logSecurityEvent({ userId: updatedUser.id, type: 'password_changed', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({
|
||||
...updatedUser,
|
||||
twoFactorRequired: requiresTwoFactor(updatedUser),
|
||||
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
|
||||
});
|
||||
const { user, twoFactorRequired, token } = await accounts.updateProfile(req.user.id, req.body || {}, { ip: getClientIp(req), userAgent: req.headers['user-agent'] });
|
||||
res.json({ ...user, twoFactorRequired, token });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Accounts: sign up, log in (with the 2FA code step) and changing your own details (1.51.2).
|
||||
* Moved from userController.js so /api/users/* (v1) and /api/v2/auth/*, /api/v2/me share it.
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import axios from 'axios';
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail, ServiceError } from './errors';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const auth = require('../config/auth') as {
|
||||
generateToken(id: string, role: string, tokenVersion: number): string;
|
||||
hashPassword(pw: string): Promise<string>;
|
||||
comparePassword(pw: string, hash: string): Promise<boolean>;
|
||||
};
|
||||
const twoFactor = require('../utils/twoFactor') as {
|
||||
createChallenge(user: any, purpose: string, method: string): Promise<{ challengeId: string; method: string; destination: string | null }>;
|
||||
verifyChallenge(challengeId: string, code: string, purpose: string): Promise<{ userId: string; method: string }>;
|
||||
issueTrustedDevice(userId: string, userAgent?: string): Promise<string>;
|
||||
isTrustedDevice(userId: string, deviceToken?: string): Promise<boolean>;
|
||||
clearTrustedDevices(userId: string): Promise<unknown>;
|
||||
requiresTwoFactor(user: { role: string }): boolean;
|
||||
enforcementMethod(user: any): Promise<string | null>;
|
||||
};
|
||||
const { logSecurityEvent } = require('../utils/securityEvents') as { logSecurityEvent(e: object): Promise<unknown> };
|
||||
const { getSettingSync } = require('../utils/settingsCache') as { getSettingSync(key: string, fallback: string): string };
|
||||
const whatsapp = () => require('../utils/whatsapp') as { normalizeZAPhone(p: unknown): string | null; isValidZAPhone(p: unknown): boolean };
|
||||
|
||||
/** Who is asking, for security notices and the activity log. */
|
||||
export type RequestInfo = { ip?: string; userAgent?: string };
|
||||
|
||||
// Org name for email subjects — falls back to Cross Code (the platform vendor)
|
||||
// rather than any specific customer's name when unconfigured.
|
||||
export function getOrgName(): string {
|
||||
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
}
|
||||
|
||||
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
||||
const frontendUrl = () => (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
/**
|
||||
* Fire-and-forget: create a 24h activation token and deliver it to an inactive account — by
|
||||
* email if it has a real (non-guest) address, otherwise by WhatsApp if it has a phone number.
|
||||
* Used when a login hits an inactive account, and when staff create an account for someone.
|
||||
*/
|
||||
export async function sendActivationLink(user: { id: string; name?: string | null; email?: string | null; phoneNumber?: string | null }): Promise<void> {
|
||||
const hasRealEmail = !!(user?.email && !user.email.endsWith('@guest.local'));
|
||||
if (!hasRealEmail && !user?.phoneNumber) return;
|
||||
try {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await prisma.passwordReset.updateMany({ where: { userId: user.id, used: false }, data: { used: true } });
|
||||
await prisma.passwordReset.create({ data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false } });
|
||||
const activationUrl = `${frontendUrl()}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
|
||||
if (hasRealEmail) {
|
||||
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
||||
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
||||
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
|
||||
.catch((e: any) => console.warn('[activation email] Failed:', e?.message || e));
|
||||
} else {
|
||||
const orgName = getOrgName();
|
||||
const waMessage = [
|
||||
`🔓 *Activate your ${orgName} account*`,
|
||||
'',
|
||||
`Hi ${user.name || 'there'},`,
|
||||
'',
|
||||
'Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:',
|
||||
'',
|
||||
activationUrl,
|
||||
'',
|
||||
'_This link expires in 24 hours._',
|
||||
].join('\n');
|
||||
const { waTextAny } = require('../utils/notify');
|
||||
waTextAny(user, waMessage).catch((e: any) => console.warn('[activation WA] Failed:', e?.message || e));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: a login notice with the approximate place it came from.
|
||||
async function sendLoginNotification(user: any, info: RequestInfo): Promise<void> {
|
||||
try {
|
||||
const ip = info.ip || 'unknown';
|
||||
const userAgent = info.userAgent || 'Unknown device';
|
||||
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
|
||||
let location = 'Unknown location';
|
||||
if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) {
|
||||
try {
|
||||
const geo = await axios.get(`http://ip-api.com/json/${ip}?fields=status,city,regionName,country`, { timeout: 3000 });
|
||||
if (geo.data?.status === 'success') {
|
||||
const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean);
|
||||
if (parts.length) location = parts.join(', ');
|
||||
}
|
||||
} catch { /* geo lookup failure is non-fatal */ }
|
||||
}
|
||||
|
||||
const { sendMail, buildLoginNotificationEmail } = require('../utils/email');
|
||||
const { buildWALogin } = require('../utils/waMessages');
|
||||
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
|
||||
// Security: always email; also WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
|
||||
const { waText } = require('../utils/notify');
|
||||
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
|
||||
} catch (e: any) {
|
||||
console.warn('[login notification] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget: the welcome email, with the next few upcoming events. */
|
||||
export async function sendWelcomeEmail(user: any): Promise<void> {
|
||||
try {
|
||||
const events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gt: new Date() } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
take: 3,
|
||||
select: { title: true, startDate: true },
|
||||
});
|
||||
const { sendMail, buildWelcomeEmail } = require('../utils/email');
|
||||
const { buildWAWelcome } = require('../utils/waMessages');
|
||||
const content = buildWelcomeEmail({ name: user.name, events });
|
||||
const { waText } = require('../utils/notify');
|
||||
// Welcome is always sent via email; also via WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
|
||||
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
|
||||
} catch (e: any) {
|
||||
console.warn('[welcome email] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fields the login flow needs once the password (and any 2FA code) has checked out.
|
||||
export const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
|
||||
noticeVersionAccepted: true,
|
||||
} as const;
|
||||
|
||||
type LoginUser = { id: string; name: string; email: string; role: string; tokenVersion: number; newsAskedAt: Date | null; noticeVersionAccepted: number | null } & Record<string, unknown>;
|
||||
|
||||
export type Session = {
|
||||
id: string; name: string; email: string; role: string; token: string;
|
||||
newsPromptPending: boolean; noticePromptPending: false | 'first' | 'updated'; deviceToken?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Final step of a successful login (after the password, and the 2FA code when required):
|
||||
* notify, record the activity, and issue the session token.
|
||||
*/
|
||||
export async function completeLogin(user: LoginUser, info: RequestInfo, extra: { deviceToken?: string } = {}): Promise<Session> {
|
||||
const noticePromptPending = await (require('../utils/privacyNotice') as { noticePromptPending(u: object): Promise<false | 'first' | 'updated'> }).noticePromptPending(user).catch(() => false as const);
|
||||
// Retention: logging in keeps the account (and cancels an inactivity warning).
|
||||
prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), inactivityWarnedAt: null } }).catch(() => {});
|
||||
sendLoginNotification(user, info).catch(() => {});
|
||||
logSecurityEvent({ userId: user.id, type: 'login', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: auth.generateToken(user.id, user.role, user.tokenVersion),
|
||||
// POPIA: ask about News & announcements once (NewsPrompt on the frontend), and to accept a
|
||||
// privacy notice version marked as a significant change (NoticePrompt).
|
||||
newsPromptPending: !user.newsAskedAt,
|
||||
noticePromptPending,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export type SignUpInput = { name: string; email: string; password: string; phoneNumber?: string | null; notificationPreference?: string; newsOptIn?: boolean };
|
||||
|
||||
/** A new account (signed in straight away), with the news choice and privacy notice recorded. */
|
||||
export async function signUp(input: SignUpInput, info: RequestInfo) {
|
||||
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = input;
|
||||
const { normalizeZAPhone, isValidZAPhone } = whatsapp();
|
||||
const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null);
|
||||
|
||||
// WhatsApp needs a valid phone number; otherwise quietly use email
|
||||
const allowedPrefs = ['email', 'whatsapp', 'both'];
|
||||
let pref = allowedPrefs.includes(String(notificationPreference)) ? String(notificationPreference) : 'email';
|
||||
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) pref = 'email';
|
||||
|
||||
const userExists = await prisma.user.findFirst({ where: { OR: [{ email }, ...(phone ? [{ phoneNumber: phone }] : [])] } });
|
||||
if (userExists) fail(409, 'User already exists', undefined, { v1Status: 400 });
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: { id: uuidv4(), name, email, password: await auth.hashPassword(password), phoneNumber: phone, notificationPreference: pref as 'email' | 'whatsapp' | 'both', updatedAt: new Date() },
|
||||
});
|
||||
|
||||
// POPIA: the sign-up form's (unticked) "news and announcements" box. Recorded either way,
|
||||
// so they aren't asked again at their first login.
|
||||
await require('../utils/privacy').setNewsPreferences(
|
||||
user.id,
|
||||
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
|
||||
{ source: 'signup', ip: info.ip, answered: true, logAll: true },
|
||||
).catch((e: any) => console.warn('[signup] news preference not saved:', e?.message || e));
|
||||
// The sign-up form requires ticking "I agree to the Terms of Use and Privacy Policy".
|
||||
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'signup', ip: info.ip })
|
||||
.catch((e: any) => console.warn('[signup] notice acceptance not saved:', e?.message || e));
|
||||
|
||||
sendWelcomeEmail(user).catch(() => {});
|
||||
return { id: user.id, name: user.name, email: user.email, role: user.role, token: auth.generateToken(user.id, user.role, user.tokenVersion) };
|
||||
}
|
||||
|
||||
export type LoginResult =
|
||||
| { status: 'signed_in'; session: Session }
|
||||
| { status: 'code_required'; challenge: { challengeId: string; method: string; destination: string | null }; enrolling: boolean };
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCK_TIME = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Checks an email (or phone number) and password. Five wrong passwords lock the account for
|
||||
* five minutes. With 2FA (or when the role needs it), a code is sent instead of a token.
|
||||
*/
|
||||
export async function logIn(input: { identifier: string; password: string; deviceToken?: string }, info: RequestInfo): Promise<LoginResult> {
|
||||
const rawInput = String(input.identifier || '');
|
||||
// SA local format (0xx) → international (27xx)
|
||||
const normalizedPhone = whatsapp().normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
|
||||
const user = await prisma.user.findFirst({ where: { OR: [{ email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : [])] } });
|
||||
if (!user) return fail(401, 'User does not exist');
|
||||
|
||||
if (!user.isActive) {
|
||||
// Resend the activation link on each attempt, in case the earlier one expired.
|
||||
await sendActivationLink(user);
|
||||
if (user.email && !user.email.endsWith('@guest.local')) fail(401, 'Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
|
||||
if (user.phoneNumber) fail(401, 'Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
|
||||
fail(401, 'Your account has been deactivated');
|
||||
}
|
||||
|
||||
if (user.lockUntil && user.lockUntil > new Date()) {
|
||||
const minutes = Math.ceil((new Date(user.lockUntil).getTime() - Date.now()) / 60000);
|
||||
fail(429, `Too many attempts. Try again in ${minutes} minute(s).`, undefined, { v1Status: 400 });
|
||||
}
|
||||
|
||||
if (!(await auth.comparePassword(input.password, user.password))) {
|
||||
const attempts = user.failedAttempts + 1;
|
||||
await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: attempts, lockUntil: attempts >= MAX_ATTEMPTS ? new Date(Date.now() + LOCK_TIME) : null } });
|
||||
fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({ where: { id: user.id }, data: { failedAttempts: 0, lockUntil: null }, select: LOGIN_USER_SELECT });
|
||||
|
||||
// Two-factor: the password alone isn't enough unless this browser was remembered earlier.
|
||||
if (updated.twoFactorEnabled && !(await twoFactor.isTrustedDevice(updated.id, input.deviceToken))) {
|
||||
return { status: 'code_required', challenge: await twoFactor.createChallenge(updated, 'login', updated.twoFactorMethod || 'email'), enrolling: false };
|
||||
}
|
||||
// Admins and supervisors must use 2FA. Not set up yet: send a code to the first channel that
|
||||
// can take one; verifying it turns 2FA on. A trusted device can't skip this.
|
||||
if (!updated.twoFactorEnabled && twoFactor.requiresTwoFactor(updated)) {
|
||||
const method = await twoFactor.enforcementMethod(updated);
|
||||
if (method) return { status: 'code_required', challenge: await twoFactor.createChallenge(updated, 'login', method), enrolling: true };
|
||||
}
|
||||
return { status: 'signed_in', session: await completeLogin(updated, info) };
|
||||
}
|
||||
|
||||
/** The second login step: the emailed or WhatsApp code for a token (and optionally remember this device). */
|
||||
export async function verifyLoginCode(input: { challengeId: string; code: string; rememberDevice?: boolean }, info: RequestInfo): Promise<Session> {
|
||||
const challenge = await twoFactor.verifyChallenge(input.challengeId, input.code, 'login');
|
||||
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
|
||||
if (!user || !user.isActive) return fail(401, 'Your account has been deactivated');
|
||||
|
||||
// First login since 2FA became required for this role: the code proves the channel works,
|
||||
// so turn 2FA on with it.
|
||||
if (!user.twoFactorEnabled && twoFactor.requiresTwoFactor(user)) {
|
||||
user = await prisma.user.update({ where: { id: user.id }, data: { twoFactorEnabled: true, twoFactorMethod: challenge.method as 'email' | 'whatsapp', updatedAt: new Date() }, select: LOGIN_USER_SELECT });
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
|
||||
}
|
||||
const extra = input.rememberDevice === true ? { deviceToken: await twoFactor.issueTrustedDevice(user.id, info.userAgent) } : {};
|
||||
return completeLogin(user, info, extra);
|
||||
}
|
||||
|
||||
export type ProfileInput = {
|
||||
name?: string; email?: string; phoneNumber?: string | null; notificationPreference?: string; homeCampusId?: string | null;
|
||||
/** A new password; needs currentPassword. */
|
||||
password?: string; currentPassword?: string;
|
||||
};
|
||||
|
||||
export const PROFILE_SELECT = {
|
||||
id: true, name: true, email: true, role: true, phoneNumber: true, notificationPreference: true, twoFactorEnabled: true,
|
||||
twoFactorMethod: true, createdAt: true, updatedAt: true, isActive: true, homeCampusId: true, tokenVersion: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Changes your own details. A new password needs the current one, signs out other devices'
|
||||
* remembered 2FA, and sends a security notice. Returns the account and a fresh token.
|
||||
*/
|
||||
export async function updateProfile(userId: string, input: ProfileInput, info: RequestInfo) {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) return fail(404, 'User not found');
|
||||
const { name, email, password, phoneNumber, currentPassword, notificationPreference, homeCampusId } = input;
|
||||
|
||||
// Home campus (only while campuses are on): an active campus, or ''/null for none.
|
||||
let newHomeCampusId = user.homeCampusId;
|
||||
if (homeCampusId !== undefined && await require('../utils/campuses').campusesEnabled()) {
|
||||
if (!homeCampusId) newHomeCampusId = null;
|
||||
else {
|
||||
const campus = await prisma.campus.findFirst({ where: { id: String(homeCampusId), archivedAt: null }, select: { id: true } });
|
||||
if (!campus) fail(400, 'Choose one of the campuses in the list.');
|
||||
newHomeCampusId = campus!.id;
|
||||
}
|
||||
}
|
||||
|
||||
// A password change needs the current password
|
||||
let newHashedPassword: string | undefined;
|
||||
if (typeof password === 'string' && password.trim().length > 0) {
|
||||
const newPw = password.trim();
|
||||
if (!currentPassword) fail(400, 'Current password is required to set a new password');
|
||||
if (!(await auth.comparePassword(currentPassword!, user.password))) fail(400, 'Current password is incorrect');
|
||||
if (newPw.length < 8) fail(400, 'New password must be at least 8 characters long');
|
||||
newHashedPassword = await auth.hashPassword(newPw);
|
||||
}
|
||||
|
||||
const { normalizeZAPhone, isValidZAPhone } = whatsapp();
|
||||
let newPhone = user.phoneNumber;
|
||||
if (phoneNumber !== undefined) newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
|
||||
|
||||
// WhatsApp needs a valid SA phone number
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
|
||||
// 2FA codes can't go to WhatsApp without a usable number — move them to email.
|
||||
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
name: name || user.name, email: email || user.email, password: newHashedPassword ?? user.password, phoneNumber: newPhone,
|
||||
notificationPreference: newPref, twoFactorMethod: newTwoFactorMethod, homeCampusId: newHomeCampusId, updatedAt: new Date(),
|
||||
},
|
||||
select: PROFILE_SELECT,
|
||||
});
|
||||
|
||||
if (newHashedPassword) {
|
||||
await twoFactor.clearTrustedDevices(updated.id);
|
||||
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
|
||||
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
|
||||
const content = buildPasswordChangedEmail({ name: updated.name, when: Date.now(), supportEmail });
|
||||
sendMail({ to: updated.email, subject: `Your ${getOrgName()} password was changed`, ...content })
|
||||
.catch((e: any) => console.warn('[email] Failed to send password changed alert:', e?.message || e));
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(updated, content.text).catch(() => {});
|
||||
logSecurityEvent({ userId: updated.id, type: 'password_changed', ip: info.ip, userAgent: info.userAgent }).catch(() => {});
|
||||
}
|
||||
|
||||
return { user: updated, twoFactorRequired: twoFactor.requiresTwoFactor(updated), token: auth.generateToken(updated.id, updated.role, updated.tokenVersion) };
|
||||
}
|
||||
|
||||
export { ServiceError };
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Checking a ticket in by its QR code (1.51.2). Moved from ticketController.js so
|
||||
* /api/tickets/scan-preview|scan (v1) and /api/v2/checkin/{qrCode} share it.
|
||||
*
|
||||
* Scanning has several normal answers besides "checked in" (already used, wrong event, consent
|
||||
* missing…). They come back as an `outcome`, not thrown, so each caller can show them its way;
|
||||
* only an unknown code is an error (404).
|
||||
*/
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { prisma } from '../v2/legacy';
|
||||
import { fail } from './errors';
|
||||
import type { Role } from './registrations';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
|
||||
const transfer = () => require('../utils/ticketTransfer') as { findTransferByOldCode(code: string): Promise<any>; transferredMessage(t: any): string };
|
||||
const consent = () => require('../utils/consent') as { consentStatusForTickets(ids: string[]): Promise<Map<string, string>> };
|
||||
type Session = { id: string; label?: string | null; startsAt: Date };
|
||||
const sessions = () => require('../utils/sessions') as {
|
||||
TIMEZONE: string;
|
||||
eventSchedule(eventId: string): Promise<{ sessions: Session[] } & Record<string, unknown>>;
|
||||
ticketScanSession(schedule: any, list: Session[], pinned: string | null, picked: string | null, now: Date, chosen: string[] | null): { session: Session | null; error: string | null };
|
||||
sessionLabel(session: Session, list: Session[]): string;
|
||||
redeemedForSession(usages: Usage[], sessionId: string): number;
|
||||
};
|
||||
type Usage = { id?: string; quantityRedeemed: number | null; scannedAt: string; sessionId: string | null };
|
||||
|
||||
export type ConsentStatus = 'not_required' | 'given' | 'pending' | 'declined' | 'missing' | string;
|
||||
|
||||
/** A ticket that was handed on: the old code tells the scanner who has it now. */
|
||||
async function transferredOutcome(qrCode: string) {
|
||||
const t = transfer();
|
||||
const found = await t.findTransferByOldCode(qrCode);
|
||||
return found ? { outcome: 'transferred' as const, message: t.transferredMessage(found) } : null;
|
||||
}
|
||||
|
||||
export type PreviewResult =
|
||||
| { outcome: 'transferred'; message: string }
|
||||
| {
|
||||
outcome: 'found';
|
||||
ticket: {
|
||||
id: string; quantity: number; isUsed: boolean; eventId: string; consentStatus: ConsentStatus; remaining: number;
|
||||
session: { id: string; label: string } | null; sessionError: string | null;
|
||||
registrationOption: { id: string | null; eventOption: { id: string | null; name: string | null }; variant: { id: string; name: string } | null };
|
||||
event: { id: string; title: string }; user: { id: string; name: string }; attendee: { name: string } | null;
|
||||
usages: Usage[];
|
||||
};
|
||||
};
|
||||
|
||||
/** What a scan would do, without checking anything in (the scanner's preview card). */
|
||||
export async function previewScan(qrCode: string, opts: { sessionId?: string | null } = {}): Promise<PreviewResult> {
|
||||
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
|
||||
const rows = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
t.id, t."qrCode", t.quantity, t."isUsed", t."eventId",
|
||||
ro.id AS "registrationOptionId", ro."sessionId" AS "pinnedSessionId", ro."sessionIds" AS "chosenSessionIds",
|
||||
eo.id AS "eventOptionId", eo.name AS "eventOptionName",
|
||||
ov.id AS "variantId", ov.name AS "variantName",
|
||||
e.id AS "evId", e.title AS "eventTitle", e."scheduleType" AS "scheduleType",
|
||||
u.id AS "userId", u.name AS "userName",
|
||||
a.name AS "attendeeName",
|
||||
COALESCE(
|
||||
json_agg(json_build_object('quantityRedeemed', tu."quantityRedeemed", 'scannedAt', tu."scannedAt", 'sessionId', tu."sessionId"))
|
||||
FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "User" u ON u.id = t."userId"
|
||||
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${qrCode}
|
||||
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
|
||||
`;
|
||||
if (!rows || rows.length === 0) return (await transferredOutcome(qrCode)) ?? fail(404, 'Ticket not found');
|
||||
|
||||
const r = rows[0];
|
||||
const consentStatus = (await consent().consentStatusForTickets([r.id])).get(r.id) || 'not_required';
|
||||
const usages: Usage[] = r.usages || [];
|
||||
// Multi-day events: check-in is per session, so what's left is counted for the
|
||||
// session this scan is for (the one picked, or today's).
|
||||
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
let session: { id: string; label: string } | null = null;
|
||||
let sessionError: string | null = null;
|
||||
if (r.scheduleType === 'sessions') {
|
||||
const s = sessions();
|
||||
const schedule = await s.eventSchedule(r.eventId);
|
||||
const found = s.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, opts.sessionId || null, new Date(), r.chosenSessionIds);
|
||||
sessionError = found.error;
|
||||
if (found.session) {
|
||||
session = { id: found.session.id, label: s.sessionLabel(found.session, schedule.sessions) };
|
||||
remaining = Number(r.quantity) - s.redeemedForSession(usages, found.session.id);
|
||||
} else {
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: 'found',
|
||||
ticket: {
|
||||
id: r.id, quantity: Number(r.quantity), isUsed: r.isUsed, eventId: r.eventId, consentStatus, remaining, session, sessionError,
|
||||
registrationOption: {
|
||||
id: r.registrationOptionId,
|
||||
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
|
||||
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
|
||||
},
|
||||
event: { id: r.evId, title: r.eventTitle },
|
||||
user: { id: r.userId, name: r.userName },
|
||||
attendee: r.attendeeName ? { name: r.attendeeName } : null,
|
||||
usages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ScanTicket = {
|
||||
id: string; quantity: number; isUsed: boolean; eventId: string; event: { title: string };
|
||||
registrationOption: { eventOption: { id: string | null; name: string | null; isMainTicket: boolean | null } };
|
||||
usages: Usage[];
|
||||
};
|
||||
|
||||
export type ScanOptions = {
|
||||
/** Refuse a ticket for any other event (the scanner's chosen event). */
|
||||
eventId?: string | null;
|
||||
/** Only tickets valid for this section. */
|
||||
sectionId?: string | null;
|
||||
/** Multi-day events: the session (default: today's). */
|
||||
sessionId?: string | null;
|
||||
/** The scanning device, so its own offline lock doesn't block it. */
|
||||
deviceId?: string | null;
|
||||
/** How many people to check in (default: everyone left). */
|
||||
qty?: number | null;
|
||||
/** Supervisors and admins: check in without parental consent (logged). */
|
||||
overrideConsent?: boolean;
|
||||
};
|
||||
|
||||
export type ScanResult =
|
||||
| { outcome: 'checked_in'; message: string; session: { id: string; label: string } | null; ticketUsage: { id: string; scannedAt: Date; quantityRedeemed: number; sessionId: string | null }; ticket: ScanTicket; qtyRedeemed: number; totalRedeemed: number; remaining: number }
|
||||
| { outcome: 'transferred'; message: string }
|
||||
| { outcome: 'wrong_event' | 'section_not_found' | 'wrong_section' | 'no_session'; message: string; ticket: ScanTicket }
|
||||
| { outcome: 'locked'; message: string }
|
||||
| { outcome: 'already_used'; message: string; ticket: ScanTicket }
|
||||
| { outcome: 'consent_required'; message: string; consentStatus: ConsentStatus; canOverride: boolean; ticket: ScanTicket };
|
||||
|
||||
/** Checks a ticket in (all of it, or `qty` people), or says why it can't be. */
|
||||
export async function scanTicket(qrCode: string, actor: { id: string; role: Role }, opts: ScanOptions = {}, ip?: string): Promise<ScanResult> {
|
||||
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
|
||||
const rows = await prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
t.id, t.quantity, t."isUsed", t."eventId",
|
||||
e.title AS "eventTitle", e."scheduleType" AS "scheduleType",
|
||||
ro."sessionId" AS "pinnedSessionId", ro."sessionIds" AS "chosenSessionIds",
|
||||
eo.id AS "eventOptionId", eo.name AS "eventOptionName", eo."isMainTicket" AS "isMainTicket",
|
||||
COALESCE(
|
||||
json_agg(json_build_object('id', tu.id, 'quantityRedeemed', tu."quantityRedeemed", 'scannedAt', tu."scannedAt", 'sessionId', tu."sessionId"))
|
||||
FILTER (WHERE tu.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS usages
|
||||
FROM "Ticket" t
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
|
||||
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${qrCode}
|
||||
GROUP BY t.id, e.id, eo.id, ro.id
|
||||
`;
|
||||
if (!rows || rows.length === 0) return (await transferredOutcome(qrCode)) ?? fail(404, 'Ticket not found');
|
||||
|
||||
const r = rows[0];
|
||||
const ticket: ScanTicket = {
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
event: { title: r.eventTitle },
|
||||
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
|
||||
usages: r.usages || [],
|
||||
};
|
||||
|
||||
// Ensure the ticket belongs to the event being scanned, when given
|
||||
const providedEventId = String(opts.eventId || '').trim();
|
||||
if (providedEventId && ticket.eventId !== providedEventId) return { outcome: 'wrong_event', message: 'Ticket belongs to a different event', ticket };
|
||||
|
||||
// Section checks used to happen only in the browser; enforce them here too.
|
||||
const sectionId = String(opts.sectionId || '').trim() || null;
|
||||
if (sectionId) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
|
||||
if (!section || section.eventId !== ticket.eventId) return { outcome: 'section_not_found', message: 'Section not found for this event', ticket };
|
||||
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) return { outcome: 'wrong_section', message: 'This ticket is not valid for this section', ticket };
|
||||
}
|
||||
|
||||
// A section (or the whole event) locked to another device for offline scanning
|
||||
// can't be scanned online — those scans would be invisible to the offline device.
|
||||
const { blockingLock } = require('../utils/scanLocks') as { blockingLock(target: object, opts: object): Promise<any> };
|
||||
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: opts.deviceId || null });
|
||||
if (lock) {
|
||||
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
|
||||
return { outcome: 'locked', message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.` };
|
||||
}
|
||||
|
||||
// Multi-day events: the check-in is for one session (the one picked, or today's), and
|
||||
// what's left is counted per session.
|
||||
let session: Session | null = null;
|
||||
let schedule: { sessions: Session[] } | null = null;
|
||||
const s = sessions();
|
||||
if (r.scheduleType === 'sessions') {
|
||||
schedule = await s.eventSchedule(ticket.eventId);
|
||||
const found = s.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(opts.sessionId || '').trim() || null, new Date(), r.chosenSessionIds);
|
||||
if (!found.session) return { outcome: 'no_session', message: found.error || 'No session is open for check-in now', ticket };
|
||||
session = found.session;
|
||||
}
|
||||
const label = session && schedule ? s.sessionLabel(session, schedule.sessions) : '';
|
||||
|
||||
const totalRedeemed = session
|
||||
? s.redeemedForSession(ticket.usages, session.id)
|
||||
: ticket.usages.reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (session) {
|
||||
const last = ticket.usages.filter(u => u.sessionId === session!.id).sort((a, b) => +new Date(b.scannedAt) - +new Date(a.scannedAt))[0];
|
||||
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: s.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
|
||||
return { outcome: 'already_used', message: `Already checked in for ${label}${at ? ` at ${at}` : ''}`, ticket };
|
||||
}
|
||||
return { outcome: 'already_used', message: 'Ticket has already been fully used', ticket };
|
||||
}
|
||||
|
||||
// Youth events: no check-in without parental consent, unless a supervisor overrides.
|
||||
const consentStatus = (await consent().consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
|
||||
if (!['not_required', 'given'].includes(consentStatus)) {
|
||||
const canOverride = ['admin', 'supervisor'].includes(actor.role);
|
||||
if (!(opts.overrideConsent === true && canOverride)) {
|
||||
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
|
||||
return { outcome: 'consent_required', message: `${why} — this ticket can't be checked in.`, consentStatus, canOverride, ticket };
|
||||
}
|
||||
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
|
||||
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'consent_override', targetType: 'Ticket', targetId: ticket.id, metadata: { consentStatus, eventId: ticket.eventId }, ip });
|
||||
}
|
||||
|
||||
// How many to check in this scan (defaults to all remaining)
|
||||
const requestedQty = Math.trunc(Number(opts.qty)) || remaining;
|
||||
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
|
||||
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
|
||||
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
|
||||
|
||||
// The usage insert and the ticket's status don't depend on each other
|
||||
const [ticketUsage] = await Promise.all([
|
||||
prisma.ticketUsage.create({
|
||||
data: { id: uuidv4(), ticketId: ticket.id, scannedById: actor.id, quantityRedeemed: qtyToRedeem, sessionId: session ? session.id : null },
|
||||
}),
|
||||
// A multi-session ticket is never "used up" — it's checked in again next session.
|
||||
prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: session ? false : newRemaining <= 0, updatedAt: new Date() } }),
|
||||
]);
|
||||
|
||||
// Only Main Tickets trigger a check-in notification
|
||||
if (ticket.registrationOption.eventOption.isMainTicket) {
|
||||
(require('../utils/notifications') as { sendCheckInEmails(id: string, qty: number, total: number, left: number): Promise<unknown> })
|
||||
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
|
||||
.catch(e => console.error('Failed to send check-in notification:', e));
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: 'checked_in',
|
||||
message: `Ticket scanned successfully${session ? ` for ${label}` : ''} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
|
||||
session: session ? { id: session.id, label } : null,
|
||||
ticketUsage,
|
||||
ticket,
|
||||
qtyRedeemed: qtyToRedeem,
|
||||
totalRedeemed: newTotalRedeemed,
|
||||
remaining: newRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
/** The HTTP status and body v1 (/api/tickets/scan/:qrCode) has always answered for each outcome. */
|
||||
export function v1ScanResponse(result: ScanResult): { status: number; body: Record<string, unknown> } {
|
||||
const { outcome, ...rest } = result;
|
||||
switch (outcome) {
|
||||
case 'checked_in': return { status: 200, body: rest };
|
||||
case 'transferred': return { status: 410, body: { ...rest, code: 'TICKET_TRANSFERRED' } };
|
||||
case 'locked': return { status: 423, body: { ...rest, code: 'SCAN_LOCKED' } };
|
||||
case 'no_session': return { status: 400, body: { ...rest, code: 'NO_SESSION' } };
|
||||
case 'already_used': return { status: 403, body: { ...rest, remaining: 0 } };
|
||||
case 'consent_required': return { status: 403, body: { ...rest, code: 'CONSENT_REQUIRED' } };
|
||||
default: return { status: 400, body: rest };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { z } from 'zod';
|
||||
import type { Request } from 'express';
|
||||
import { defineRoute } from '../route';
|
||||
import { single } from '../paging';
|
||||
import { id } from '../schemas';
|
||||
import auth from '../../middleware/authMiddleware';
|
||||
import { signUp, logIn, verifyLoginCode, type Session } from '../../services/auth';
|
||||
|
||||
const requestInfo = (req: Request) => {
|
||||
const { getClientIp } = require('../../utils/requestUtils') as { getClientIp(req: unknown): string };
|
||||
return { ip: getClientIp(req), userAgent: req.headers['user-agent'] };
|
||||
};
|
||||
|
||||
const session = z.object({
|
||||
status: z.literal('signed_in'),
|
||||
token: z.string().describe('Send as `Authorization: Bearer <token>`'),
|
||||
user: z.object({ id, name: z.string(), email: z.string(), role: z.enum(['admin', 'supervisor', 'staff', 'user']) }),
|
||||
newsPromptPending: z.boolean().describe('Ask once whether they want News & announcements'),
|
||||
noticePromptPending: z.union([z.literal(false), z.enum(['first', 'updated'])]).describe('A privacy notice they must accept: never accepted (first) or a newer version (updated)'),
|
||||
deviceToken: z.string().optional().describe('With rememberDevice: send it with the next login to skip the code on this device'),
|
||||
});
|
||||
|
||||
const toSession = (s: Session): z.input<typeof session> => ({
|
||||
status: 'signed_in',
|
||||
token: s.token,
|
||||
user: { id: s.id, name: s.name, email: s.email, role: s.role as 'admin' | 'supervisor' | 'staff' | 'user' },
|
||||
newsPromptPending: s.newsPromptPending,
|
||||
noticePromptPending: s.noticePromptPending,
|
||||
...(s.deviceToken ? { deviceToken: s.deviceToken } : {}),
|
||||
});
|
||||
|
||||
export const signup = defineRoute({
|
||||
method: 'post',
|
||||
path: '/auth/signup',
|
||||
tag: 'Accounts',
|
||||
summary: 'Create an account',
|
||||
description: 'Signs the new account in straight away. `acceptTerms` must be true (the Terms of Use and Privacy Policy); `newsOptIn` is the unticked "News & announcements" box.',
|
||||
access: 'public',
|
||||
status: 201,
|
||||
body: z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
email: z.email().max(200),
|
||||
password: z.string().min(8).max(200),
|
||||
phone: z.string().trim().max(40).optional(),
|
||||
notificationPreference: z.enum(['email', 'whatsapp', 'both']).optional().describe('WhatsApp needs a valid South African number; otherwise email is used'),
|
||||
newsOptIn: z.boolean().optional(),
|
||||
acceptTerms: z.literal(true),
|
||||
}),
|
||||
response: single(session),
|
||||
async handler({ body, req }) {
|
||||
const made = await signUp({ name: body.name, email: body.email, password: body.password, phoneNumber: body.phone, notificationPreference: body.notificationPreference, newsOptIn: body.newsOptIn }, requestInfo(req));
|
||||
return { data: { status: 'signed_in' as const, token: made.token, user: { id: made.id, name: made.name, email: made.email, role: made.role }, newsPromptPending: false, noticePromptPending: false as const } };
|
||||
},
|
||||
});
|
||||
|
||||
export const login = defineRoute({
|
||||
method: 'post',
|
||||
path: '/auth/login',
|
||||
tag: 'Accounts',
|
||||
summary: 'Log in',
|
||||
description: [
|
||||
'With an email address or phone number. Five wrong passwords lock the account for five minutes (`RATE_LIMITED`).',
|
||||
'With two-factor authentication (always for supervisors and admins), the answer is `code_required`: a code was sent by email or WhatsApp — finish with `POST /auth/login/code`.',
|
||||
].join('\n\n'),
|
||||
access: 'public',
|
||||
middleware: [auth.loginLimiter],
|
||||
body: z.object({
|
||||
identifier: z.string().trim().min(1).max(200).describe('Email address or phone number'),
|
||||
password: z.string().min(1).max(200),
|
||||
deviceToken: z.string().max(500).optional().describe('From an earlier login with rememberDevice'),
|
||||
}),
|
||||
response: single(z.discriminatedUnion('status', [
|
||||
session,
|
||||
z.object({
|
||||
status: z.literal('code_required'),
|
||||
challengeId: z.string(),
|
||||
method: z.enum(['email', 'whatsapp']),
|
||||
destination: z.string().nullable().describe('Where the code went, partly hidden'),
|
||||
enrolling: z.boolean().describe('true = this turns two-factor on (required for the role)'),
|
||||
}),
|
||||
])),
|
||||
async handler({ body, req }) {
|
||||
const result = await logIn(body, requestInfo(req));
|
||||
if (result.status === 'signed_in') return { data: toSession(result.session) };
|
||||
return { data: { status: 'code_required' as const, challengeId: result.challenge.challengeId, method: result.challenge.method as 'email' | 'whatsapp', destination: result.challenge.destination ?? null, enrolling: result.enrolling } };
|
||||
},
|
||||
});
|
||||
|
||||
export const loginCode = defineRoute({
|
||||
method: 'post',
|
||||
path: '/auth/login/code',
|
||||
tag: 'Accounts',
|
||||
summary: 'Finish logging in with the code',
|
||||
access: 'public',
|
||||
middleware: [auth.loginLimiter],
|
||||
body: z.object({
|
||||
challengeId: z.string().min(1).max(100),
|
||||
code: z.string().trim().min(4).max(12),
|
||||
rememberDevice: z.boolean().optional().describe('Skip the code on this device next time (30 days)'),
|
||||
}),
|
||||
response: single(session),
|
||||
async handler({ body, req }) {
|
||||
return { data: toSession(await verifyLoginCode(body, requestInfo(req))) };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,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 };
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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/`),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+2
-2
@@ -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,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("");
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { AuthResponse, AuthUser, TwoFactorChallenge, clearToken, getDeviceToken, getToken, isTwoFactorChallenge, saveDeviceToken, saveToken } from "@/lib/auth";
|
||||
import { AuthResponse, AuthUser, TwoFactorChallenge, clearToken, getDeviceToken, getToken, saveDeviceToken, saveToken } from "@/lib/auth";
|
||||
import { apiV2, type V2Result } from "@/lib/apiV2";
|
||||
|
||||
// A v2 "signed_in" answer as the session the app keeps (the user's fields plus the token).
|
||||
type V2Session = Extract<V2Result<"/auth/login", "post">["data"], { status: "signed_in" }>;
|
||||
const toAuthResponse = (s: V2Session): AuthResponse => ({
|
||||
...s.user, token: s.token, newsPromptPending: s.newsPromptPending, noticePromptPending: s.noticePromptPending,
|
||||
...(s.deviceToken ? { deviceToken: s.deviceToken } : {}),
|
||||
});
|
||||
|
||||
export type AuthContextValue = {
|
||||
user: AuthUser | null;
|
||||
@@ -91,22 +99,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
|
||||
method: "POST",
|
||||
body: { email, password, deviceToken: getDeviceToken() || undefined },
|
||||
const { data } = await apiV2("post", "/auth/login", {
|
||||
body: { identifier: email, password, deviceToken: getDeviceToken() || undefined },
|
||||
});
|
||||
// 2FA accounts get a challenge back instead of a token — nothing is saved
|
||||
// until verifyTwoFactor exchanges the emailed/WhatsApp code for one.
|
||||
if (isTwoFactorChallenge(res)) return res;
|
||||
return startSession(res as AuthResponse);
|
||||
if (data.status === "code_required") {
|
||||
const challenge: TwoFactorChallenge = { twoFactorRequired: true, challengeId: data.challengeId, method: data.method, destination: data.destination || "", ...(data.enrolling ? { enrolling: true } : {}) };
|
||||
return challenge;
|
||||
}
|
||||
return startSession(toAuthResponse(data));
|
||||
}, [startSession]);
|
||||
|
||||
const verifyTwoFactor = useCallback(async (challengeId: string, code: string, rememberDevice: boolean) => {
|
||||
const res = await apiFetch<AuthResponse>("/api/users/login/2fa", {
|
||||
method: "POST",
|
||||
body: { challengeId, code, rememberDevice },
|
||||
});
|
||||
return startSession(res);
|
||||
const { data } = await apiV2("post", "/auth/login/code", { body: { challengeId, code, rememberDevice } });
|
||||
return startSession(toAuthResponse(data));
|
||||
}, [startSession]);
|
||||
|
||||
const resendTwoFactor = useCallback((challengeId: string) => {
|
||||
@@ -117,11 +124,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both"; newsOptIn?: boolean }) => {
|
||||
const res = await apiFetch<AuthResponse>("/api/users", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
// The sign-up form only submits once "I agree to the Terms of Use and Privacy Policy" is ticked.
|
||||
const { data: made } = await apiV2("post", "/auth/signup", {
|
||||
body: {
|
||||
name: data.name, email: data.email, password: data.password, acceptTerms: true,
|
||||
...(data.phoneNumber ? { phone: data.phoneNumber } : {}),
|
||||
...(data.notificationPreference ? { notificationPreference: data.notificationPreference } : {}),
|
||||
...(data.newsOptIn !== undefined ? { newsOptIn: data.newsOptIn } : {}),
|
||||
},
|
||||
});
|
||||
const { token, ...registeredUser } = res;
|
||||
const { token, ...registeredUser } = toAuthResponse(made);
|
||||
saveToken(token);
|
||||
setToken(token);
|
||||
setUser(registeredUser);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+562
-1
@@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user