Require acceptance for ticket transfers; privacy and legal updates
Transfers now send the new holder a link (to the email/phone typed) and only complete once they accept; they can decline and the sender can cancel. No response reveals whether contact details belong to an account, and attendee account links are no longer sent to payers. Terms of Use and Privacy Policy are updated for all features since 1.12, with a full cookie policy and a "Cookie settings" control. The /docs route list now covers every mounted route, and account activation auto-login is fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
This commit is contained in:
@@ -2254,7 +2254,7 @@ const updateAttendees = async (req, res) => {
|
||||
where: { id: registration.id },
|
||||
select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE },
|
||||
});
|
||||
res.json(updated);
|
||||
res.json({ ...updated, attendees: updated.attendees.map(({ userId, ...a }) => a) });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ const getUserTickets = async (req, res) => {
|
||||
},
|
||||
user: { select: { id: true, name: true } },
|
||||
event: true,
|
||||
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
|
||||
usages: {
|
||||
include: {
|
||||
scannedBy: {
|
||||
@@ -703,20 +704,84 @@ const sendTicketsTo = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Transfer a ticket to someone else (new QR code; billing stays with the payer)
|
||||
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
|
||||
// @desc Offer a ticket to someone else: sends them a link to accept (nothing changes until they do)
|
||||
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }
|
||||
// @access Private (payer or holder; supervisors always)
|
||||
const transferTicketHandler = async (req, res) => {
|
||||
try {
|
||||
const { transferTicket } = require('../utils/ticketTransfer');
|
||||
const { requestTransfer } = require('../utils/ticketTransfer');
|
||||
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
|
||||
const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
|
||||
const { request, sent } = await requestTransfer({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
|
||||
res.status(201).json({
|
||||
message: sent.length
|
||||
? `We've sent ${request.toName} a link by ${sent.join(' and ')} to accept the ticket. It moves to them once they accept — until then it's still yours.`
|
||||
: `The transfer is waiting for ${request.toName} to accept, but the link couldn't be sent. Check the email or phone number.`,
|
||||
requestId: request.id,
|
||||
expiresAt: request.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Withdraw a transfer that hasn't been accepted yet
|
||||
// @route DELETE /api/tickets/:id/transfer
|
||||
// @access Private (payer or holder; supervisors)
|
||||
const cancelTransferHandler = async (req, res) => {
|
||||
try {
|
||||
const { cancelTransfer } = require('../utils/ticketTransfer');
|
||||
await cancelTransfer(req.params.id, req.user);
|
||||
res.json({ message: 'Transfer cancelled — the ticket is still yours.' });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc What's on offer behind a transfer link
|
||||
// @route GET /api/tickets/transfer-offer/:token
|
||||
// @access Public (link holder)
|
||||
const getTransferOffer = async (req, res) => {
|
||||
try {
|
||||
const { findRequestByToken } = require('../utils/ticketTransfer');
|
||||
const r = await findRequestByToken(req.params.token);
|
||||
if (!r) return res.status(404).json({ message: 'This transfer link isn’t valid.' });
|
||||
const t = r.ticket;
|
||||
res.json({
|
||||
status: r.status, toName: r.toName, expiresAt: r.expiresAt,
|
||||
fromName: (t.attendee?.name || t.user?.name || r.requestedBy.name || '').split(' ')[0],
|
||||
event: t.event,
|
||||
option: t.registrationOption.variant ? `${t.registrationOption.eventOption.name} — ${t.registrationOption.variant.name}` : t.registrationOption.eventOption.name,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/tickets/transfer-offer/:token/decline
|
||||
const declineTransferOffer = async (req, res) => {
|
||||
try {
|
||||
const { declineTransfer } = require('../utils/ticketTransfer');
|
||||
await declineTransfer(req.params.token);
|
||||
res.json({ message: 'You declined the ticket. Nothing else will happen.' });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Accept a transferred ticket — the transfer happens now
|
||||
// @route POST /api/tickets/transfer-offer/:token/accept
|
||||
// @access Public (link holder)
|
||||
const acceptTransferOffer = async (req, res) => {
|
||||
try {
|
||||
const { acceptTransfer } = require('../utils/ticketTransfer');
|
||||
const r = await acceptTransfer(req.params.token);
|
||||
const actor = r.request.requestedBy;
|
||||
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
|
||||
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode },
|
||||
actorId: actor.id, actorRole: actor.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
|
||||
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode, acceptedVia: 'link' },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
@@ -744,7 +809,7 @@ const transferTicketHandler = async (req, res) => {
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `Ticket transferred to ${a.name}. We've sent them the new ticket${r.needsConsent ? ' and a parental consent request' : ''}. The old QR code no longer works.`,
|
||||
message: `The ticket is yours! We've sent it to you${r.needsConsent ? ', and asked your parent or guardian for consent' : ''}.${r.accountCreated ? ' We have also sent you a link to activate your account, where you can see your tickets.' : ''}`,
|
||||
ticketId: r.target.id,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -980,5 +1045,9 @@ module.exports = {
|
||||
getRecentScans,
|
||||
getScanStats,
|
||||
transferTicketHandler,
|
||||
cancelTransferHandler,
|
||||
getTransferOffer,
|
||||
acceptTransferOffer,
|
||||
declineTransferOffer,
|
||||
getTicketTransfers
|
||||
};
|
||||
@@ -392,6 +392,7 @@ app.get('/docs', async (req, res) => {
|
||||
// ── Endpoint data ────────────────────────────────────────────────────────
|
||||
const GROUPS = [
|
||||
{ title: 'Users', base: '/api/users', endpoints: [
|
||||
{ method:'GET', path:'/api/users/activity', auth:'Bearer', desc:'Recent account activity (logins, password changes) for the current user' },
|
||||
{ method:'POST', path:'/api/users', auth:'public', desc:'Register a new account',
|
||||
request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }},
|
||||
responses:[
|
||||
@@ -515,6 +516,14 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Events', base: '/api/events', endpoints: [
|
||||
{ method:'GET', path:'/api/events/admin/all', auth:'admin', desc:'All events, including inactive ones' },
|
||||
{ method:'GET', path:'/api/events/:id/ics', auth:'Public (optional login)', desc:'Download an .ics calendar file for an event' },
|
||||
{ method:'GET', path:'/api/events/:id/attachments', auth:'Public', desc:'List the downloadable attachments of an event' },
|
||||
{ method:'POST', path:'/api/events/:id/attachments', auth:'supervisor+', desc:'Upload an attachment (multipart, field "file")' },
|
||||
{ method:'DELETE', path:'/api/events/:eventId/attachments/:attachmentId', auth:'supervisor+', desc:'Delete an attachment' },
|
||||
{ method:'GET', path:'/api/events/attachments/status', auth:'admin', desc:'Diagnostics for the attachments pipeline' },
|
||||
{ method:'POST', path:'/api/events/attachments/sync', auth:'admin', desc:'Re-sync attachment records with the files on disk' },
|
||||
{ method:'POST', path:'/api/events/options/:id/variants', auth:'supervisor+', desc:'Add a variant (e.g. a size) to an event option' },
|
||||
{ method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.',
|
||||
queryParams:{ limit:'Max results (default 20)' },
|
||||
responses:[
|
||||
@@ -592,6 +601,12 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Registrations', base: '/api/registrations', endpoints: [
|
||||
{ method:'PUT', path:'/api/registrations/:id/options', auth:'Bearer (owner or staff)', desc:'Change the items on a registration (add/remove), within stock and paid-ticket limits' },
|
||||
{ method:'POST', path:'/api/registrations/:id/coupon', auth:'Bearer (owner or staff)', desc:'Apply a coupon code to an existing registration', request:{ body:{ code:'STAFF50' }}},
|
||||
{ method:'DELETE', path:'/api/registrations/:id/coupon', auth:'staff+', desc:'Remove the coupon from a registration (frees the use)' },
|
||||
{ method:'GET', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Saved draft of the attendee forms' },
|
||||
{ method:'PUT', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Save a draft of the attendee forms (not submitted)' },
|
||||
{ method:'PUT', path:'/api/registrations/:id/forms/responses', auth:'supervisor+', desc:'Replace (edit) the submitted form responses of a registration' },
|
||||
{ method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.',
|
||||
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
|
||||
responses:[
|
||||
@@ -644,6 +659,7 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Impersonation', base: '/api/admin', endpoints: [
|
||||
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
|
||||
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
|
||||
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
|
||||
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
|
||||
@@ -688,6 +704,10 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
|
||||
]},
|
||||
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
|
||||
{ method:'DELETE', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Withdraw a transfer offer that has not been accepted yet' },
|
||||
{ method:'GET', path:'/api/tickets/transfer-offer/:token', auth:'Link holder', desc:'The ticket offered behind a transfer link (status, event, ticket type, sender first name)' },
|
||||
{ method:'POST', path:'/api/tickets/transfer-offer/:token/accept', auth:'Link holder', desc:'Accept the ticket — the transfer happens now (new QR code, ticket sent to the new holder)' },
|
||||
{ method:'POST', path:'/api/tickets/transfer-offer/:token/decline', auth:'Link holder', desc:'Decline the ticket offer' },
|
||||
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
|
||||
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
|
||||
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
|
||||
@@ -748,6 +768,10 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
|
||||
]},
|
||||
{ title: 'Payments', base: '/api/payments', endpoints: [
|
||||
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
|
||||
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
|
||||
{ method:'POST', path:'/api/payments/yoco-checkout/send', auth:'supervisor+', desc:'Send a generated Yoco payment link to the registrant by email or WhatsApp', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
|
||||
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
|
||||
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
|
||||
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
|
||||
responses:[
|
||||
@@ -787,6 +811,10 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Tickets', base: '/api/tickets', endpoints: [
|
||||
{ method:'GET', path:'/api/tickets', auth:'supervisor+', desc:'All tickets' },
|
||||
{ method:'GET', path:'/api/tickets/:id', auth:'Bearer (owner or staff)', desc:'A ticket by id' },
|
||||
{ method:'GET', path:'/api/tickets/qr/:qrCode', auth:'staff+', desc:'A ticket by QR code' },
|
||||
{ method:'PUT', path:'/api/tickets/email-sent', auth:'admin', desc:'Mark tickets as emailed' },
|
||||
{ method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]},
|
||||
{ method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)',
|
||||
@@ -866,6 +894,8 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Reports', base: '/api/reports', endpoints: [
|
||||
{ method:'POST', path:'/api/reports/excel', auth:'Bearer', desc:'Build an Excel file from a report payload' },
|
||||
{ method:'POST', path:'/api/reports/whatsapp', auth:'Bearer', desc:'Send a report PDF by WhatsApp' },
|
||||
{ method:'POST', path:'/api/reports/pdf', auth:'user+', desc:'Generate a PDF report of own registrations and tickets and return it as a binary download',
|
||||
responses:[{ status:200, desc:'PDF file (application/pdf)', body:'<binary PDF>' }]},
|
||||
{ method:'POST', path:'/api/reports/email', auth:'user+', desc:'Generate PDF report and email it to the authenticated user',
|
||||
@@ -890,6 +920,8 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Cashups', base: '/api/cashups', endpoints: [
|
||||
{ method:'GET', path:'/api/cashups/event/:eventId/cash-by-user', auth:'supervisor+', desc:'Payments recorded for an event, by staff member and method' },
|
||||
{ method:'PUT', path:'/api/cashups/event/:eventId/person-cash/:userId', auth:'supervisor+', desc:'Enter one staff member\'s physical cash count for an event' },
|
||||
{ method:'GET', path:'/api/cashups/event/:eventId', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history',
|
||||
pathParams:{ ':eventId':'Event UUID' },
|
||||
responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]},
|
||||
@@ -911,6 +943,8 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Uploads', base: '/api/uploads', endpoints: [
|
||||
{ method:'POST', path:'/api/uploads/logo', auth:'admin (open during first-run setup)', desc:'Upload the site logo (multipart, field "image")' },
|
||||
{ method:'POST', path:'/api/uploads/favicon', auth:'admin (open during first-run setup)', desc:'Upload the site favicon (multipart, field "image")' },
|
||||
{ method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.',
|
||||
notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.',
|
||||
responses:[
|
||||
@@ -967,6 +1001,10 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
|
||||
{ method:'POST', path:'/api/whatsapp/start', auth:'admin', desc:'Start (or restart) the WAWP WhatsApp instance' },
|
||||
{ method:'POST', path:'/api/whatsapp/request-code', auth:'admin', desc:'Request a pairing code to link a WhatsApp number' },
|
||||
{ method:'POST', path:'/api/whatsapp/delete-instance', auth:'admin', desc:'Delete the WAWP instance' },
|
||||
{ method:'POST', path:'/api/whatsapp/webhook', auth:'—', desc:'WAWP event webhook (alias of /api/webhooks/whatsapp)' },
|
||||
{ method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)',
|
||||
responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]},
|
||||
{ method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.',
|
||||
@@ -987,6 +1025,17 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Logged out', body:{ message:'Logged out.' }}]},
|
||||
]},
|
||||
|
||||
{ title: 'Dashboard stats', base: '/api/stats', endpoints: [
|
||||
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
|
||||
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
|
||||
]},
|
||||
{ title: 'Backups', base: '/api/backups', endpoints: [
|
||||
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
|
||||
{ method:'POST', path:'/api/backups/run', auth:'admin', desc:'Run a backup now' },
|
||||
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
|
||||
]},
|
||||
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
|
||||
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
|
||||
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
|
||||
|
||||
@@ -15,6 +15,10 @@ const {
|
||||
getRecentScans,
|
||||
getScanStats,
|
||||
transferTicketHandler,
|
||||
cancelTransferHandler,
|
||||
getTransferOffer,
|
||||
acceptTransferOffer,
|
||||
declineTransferOffer,
|
||||
getTicketTransfers
|
||||
} = require('../controllers/ticketController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
@@ -24,6 +28,11 @@ router.get('/mytickets', protect, getUserTickets);
|
||||
router.get('/:id', protect, getTicketById);
|
||||
router.post('/email', protect, emailTickets);
|
||||
router.post('/:id/transfer', protect, transferTicketHandler);
|
||||
router.delete('/:id/transfer', protect, cancelTransferHandler);
|
||||
// Transfer links (public: the person the ticket is offered to)
|
||||
router.get('/transfer-offer/:token', getTransferOffer);
|
||||
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
|
||||
router.post('/transfer-offer/:token/decline', declineTransferOffer);
|
||||
router.get('/:id/transfers', protect, getTicketTransfers);
|
||||
|
||||
// Staff routes
|
||||
|
||||
@@ -40,11 +40,9 @@ async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
|
||||
*/
|
||||
async function findOrCreateAccount({ name, email, phone }, db = prisma) {
|
||||
const match = await findAccountByContact({ email, phone }, db);
|
||||
if (match.conflict) {
|
||||
const err = new Error('That email address and phone number belong to two different accounts. Use just one of them.');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
// Email and phone pointing at two different accounts: go with the email's account,
|
||||
// without saying so — the person typing the details mustn't learn who has an account.
|
||||
if (match.conflict) return { user: match.emailUser, created: false };
|
||||
if (match.user) return { user: match.user, created: false };
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { hashPassword } = require('../config/auth');
|
||||
|
||||
@@ -346,6 +346,11 @@ function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
|
||||
*/
|
||||
function attachComputedTotals(registration) {
|
||||
if (!registration) return registration;
|
||||
// Never send which named attendees have an account: the person who typed their email or
|
||||
// phone mustn't be able to tell (the link is used server-side only).
|
||||
if (Array.isArray(registration.attendees)) {
|
||||
registration.attendees = registration.attendees.map(({ userId, ...a }) => a);
|
||||
}
|
||||
let lastPaymentAt = null;
|
||||
try {
|
||||
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/**
|
||||
* Ticket transfer — handing a ticket to someone else before the event.
|
||||
*
|
||||
* Privacy first: the sender only types the new holder's name and email/phone — there is
|
||||
* no user search, and nothing tells the sender whether those details belong to an
|
||||
* account. A TicketTransferRequest is created and a link is sent to the details typed;
|
||||
* nothing changes on the ticket until the new holder accepts (requestTransfer →
|
||||
* acceptTransfer). Accepting runs the transfer itself:
|
||||
*
|
||||
* - The new holder becomes the ticket's attendee (name, contact details, account). The
|
||||
* payer stays responsible for the registration; billing doesn't move.
|
||||
* - The QR code is replaced, so screenshots of the old ticket stop working. The old code
|
||||
@@ -17,6 +23,13 @@ const { validateAttendees } = require('./attendees');
|
||||
const { isMinor } = require('./consent');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
const OFFER_DAYS = 7;
|
||||
const crypto = require('crypto');
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`transfer:${token}`).digest('hex');
|
||||
function transferLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/transfer/${token}`;
|
||||
}
|
||||
|
||||
function transferError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
@@ -71,7 +84,7 @@ async function transferTicket({ ticketId, actor, ...holder }) {
|
||||
|
||||
const { user: toUser, created } = await findOrCreateAccount({ name: clean.name, email: clean.email, phone: clean.phone });
|
||||
const currentHolderId = ticket.attendee ? ticket.attendee.userId || ticket.holderUserId : ticket.holderUserId || ticket.userId;
|
||||
if (currentHolderId && currentHolderId === toUser.id) throw transferError(`${clean.name} already holds this ticket.`);
|
||||
if (currentHolderId && currentHolderId === toUser.id) throw transferError('This ticket is already yours.');
|
||||
|
||||
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
|
||||
const attendeeData = {
|
||||
@@ -126,6 +139,141 @@ async function transferTicket({ ticketId, actor, ...holder }) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Checks a transfer the way transferTicket will, without touching anything. */
|
||||
async function checkTransfer({ ticketId, actor, ...holder }) {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: ticketId },
|
||||
include: {
|
||||
usages: { select: { id: true } },
|
||||
attendee: true, holder: { select: { name: true } }, user: { select: { name: true } },
|
||||
event: { include: { eventOptions: true } },
|
||||
registrationOption: { include: { eventOption: { select: { name: true } }, registration: { select: { id: true, status: true } } } },
|
||||
},
|
||||
});
|
||||
if (!ticket) throw transferError('Ticket not found', 404);
|
||||
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
|
||||
if (blocker) throw transferError(blocker.message, blocker.status);
|
||||
const ro = ticket.registrationOption;
|
||||
const [clean] = validateAttendees({ ...ticket.event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
|
||||
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
|
||||
}]);
|
||||
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
|
||||
return { ticket, clean };
|
||||
}
|
||||
|
||||
async function sendTransferOffer(request, ticket, token, fromName) {
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||||
const { waTextAny } = require('./notify');
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const link = transferLink(token);
|
||||
const event = ticket.event;
|
||||
const when = new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const subject = `${fromName} wants to give you a ticket for ${event.title}`;
|
||||
const sent = [];
|
||||
if (request.toEmail) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">You've been offered a ticket</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.toName}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${fromName} would like to give you their ticket for <strong>${event.title}</strong> on <strong>${when}</strong>. Accept it to get your own ticket and QR code.</p>
|
||||
${ctaButton('View and accept the ticket', link)}
|
||||
${fallbackLink(link)}
|
||||
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">If you weren't expecting this, you can ignore this email or decline from the link.</p>
|
||||
`, { preheader: subject });
|
||||
try {
|
||||
await sendMail({ to: request.toEmail, subject, html, text: `Hi ${request.toName},\n\n${fromName} would like to give you their ticket for ${event.title} on ${when}. View and accept it here: ${link}` });
|
||||
sent.push('email');
|
||||
} catch (e) {
|
||||
console.warn('[transfer] offer email failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
if (request.toPhone && isValidZAPhone(request.toPhone)) {
|
||||
await waTextAny({ name: request.toName, phoneNumber: request.toPhone }, `Hi ${request.toName}, ${fromName} would like to give you their ticket for *${event.title}* on ${when}. View and accept it here: ${link}`);
|
||||
sent.push('WhatsApp');
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a transfer: checks it, then sends the new holder a link to accept. Nothing on
|
||||
* the ticket changes yet, and no account is looked up.
|
||||
*/
|
||||
async function requestTransfer({ ticketId, actor, ...holder }) {
|
||||
const { ticket, clean } = await checkTransfer({ ticketId, actor, ...holder });
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
const expiresAt = new Date(Math.min(new Date(ticket.event.startDate).getTime(), Date.now() + OFFER_DAYS * 864e5));
|
||||
const request = await prisma.$transaction(async (tx) => {
|
||||
// One open offer per ticket: a new one replaces the old.
|
||||
await tx.ticketTransferRequest.updateMany({ where: { ticketId: ticket.id, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
|
||||
return tx.ticketTransferRequest.create({
|
||||
data: {
|
||||
ticketId: ticket.id, requestedById: actor.id, toName: clean.name, toEmail: clean.email, toPhone: clean.phone,
|
||||
details: { dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone },
|
||||
tokenHash: hashToken(token), expiresAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
const fromName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || actor.name || 'Someone';
|
||||
const sent = await sendTransferOffer(request, ticket, token, fromName);
|
||||
return { request, sent };
|
||||
}
|
||||
|
||||
async function findRequestByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 200) return null;
|
||||
const request = await prisma.ticketTransferRequest.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: {
|
||||
requestedBy: { select: { id: true, name: true, role: true } },
|
||||
ticket: { include: { event: { select: { id: true, title: true, startDate: true, location: true } }, registrationOption: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } }, attendee: { select: { name: true } }, user: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
if (request && request.status === 'pending' && request.expiresAt <= new Date()) {
|
||||
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'expired' } });
|
||||
request.status = 'expired';
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/** The new holder accepts: the transfer happens now, with every check run again. */
|
||||
async function acceptTransfer(token) {
|
||||
const request = await findRequestByToken(token);
|
||||
if (!request) throw transferError('This transfer link isn’t valid.', 404);
|
||||
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
|
||||
// Claim it first so a double-click can't run the transfer twice.
|
||||
const claimed = await prisma.ticketTransferRequest.updateMany({ where: { id: request.id, status: 'pending' }, data: { status: 'accepted', respondedAt: new Date() } });
|
||||
if (claimed.count !== 1) throw transferError('This transfer has already been answered.', 409);
|
||||
try {
|
||||
const d = request.details || {};
|
||||
const result = await transferTicket({
|
||||
ticketId: request.ticketId, actor: request.requestedBy,
|
||||
name: request.toName, email: request.toEmail, phone: request.toPhone,
|
||||
dateOfBirth: d.dateOfBirth, gender: d.gender, parentName: d.parentName, parentEmail: d.parentEmail, parentPhone: d.parentPhone,
|
||||
});
|
||||
return { ...result, request };
|
||||
} catch (e) {
|
||||
// Couldn't go ahead (e.g. the ticket was scanned meanwhile): the offer is void.
|
||||
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'cancelled' } }).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function declineTransfer(token) {
|
||||
const request = await findRequestByToken(token);
|
||||
if (!request) throw transferError('This transfer link isn’t valid.', 404);
|
||||
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
|
||||
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'declined', respondedAt: new Date() } });
|
||||
return request;
|
||||
}
|
||||
|
||||
/** The sender (or a supervisor) withdraws an offer that hasn't been accepted yet. */
|
||||
async function cancelTransfer(ticketId, actor) {
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId }, select: { userId: true, holderUserId: true } });
|
||||
if (!ticket) throw transferError('Ticket not found', 404);
|
||||
if (!SUPERVISORS.includes(actor.role) && ticket.userId !== actor.id && ticket.holderUserId !== actor.id) throw transferError('Not authorized', 403);
|
||||
const { count } = await prisma.ticketTransferRequest.updateMany({ where: { ticketId, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
|
||||
if (!count) throw transferError('There is no transfer waiting for this ticket.', 404);
|
||||
}
|
||||
|
||||
/** The transfer that retired this QR code, if any. */
|
||||
async function findTransferByOldCode(qrCode) {
|
||||
if (!qrCode) return null;
|
||||
@@ -137,4 +285,7 @@ function transferredMessage(transfer) {
|
||||
return `This ticket was transferred to ${transfer.toName} on ${when}. The old QR code no longer works — ask for the new ticket.`;
|
||||
}
|
||||
|
||||
module.exports = { transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage };
|
||||
module.exports = {
|
||||
transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage,
|
||||
checkTransfer, requestTransfer, findRequestByToken, acceptTransfer, declineTransfer, cancelTransfer,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user