Files
hope-events/backend/src/utils/ticketTransfer.js
T
joshuaandClaude Opus 5.5 686616cc18 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
2026-10-19 18:46:15 +02:00

292 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
* is kept on the TicketTransfer so scanners can say "transferred" instead of "not found".
* - A shared (unnamed) ticket for several places has one place split off into a new
* ticket for the new holder; the rest stay on the shared ticket and its code.
* - The previous holder's attendee form answers are removed, and on youth events the new
* holder needs their own parental consent.
*/
const { v4: uuidv4 } = require('uuid');
const prisma = require('../config/db');
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);
err.statusCode = statusCode;
return err;
}
/**
* Why this ticket can't be transferred by `actor`, or null when it can. Pure.
* @param {object} t - { userId, holderUserId, usages[], event: { startDate, allowTicketTransfer }, registrationStatus }
* @param {object} actor - { id, role }
*/
function transferBlocker(t, actor, now = new Date()) {
const supervisor = SUPERVISORS.includes(actor.role);
if (!supervisor && t.userId !== actor.id && t.holderUserId !== actor.id) return { status: 403, message: 'Only the person who booked or holds this ticket can transfer it.' };
if (t.registrationStatus === 'cancelled') return { status: 400, message: 'This registration was cancelled.' };
if (new Date(t.event.startDate) <= now) return { status: 400, message: 'Tickets can only be transferred before the event starts.' };
if ((t.usages || []).length > 0) return { status: 400, message: 'This ticket has already been used (or partly used), so it can’t be transferred.' };
if (t.event.allowTicketTransfer === false && !supervisor) return { status: 400, message: 'Ticket transfers are turned off for this event.' };
return null;
}
/**
* Transfers a ticket. Returns what the caller needs to notify people afterwards.
* @param {object} input - { ticketId, actor, name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
*/
async function transferTicket({ ticketId, actor, ...holder }) {
const { findOrCreateAccount } = require('./accountMatch');
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
usages: { select: { id: true } },
attendee: true,
holder: { select: { id: true, name: true } },
user: { select: { id: true, name: true } },
event: { include: { eventOptions: true } },
registrationOption: { include: { 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 event = ticket.event;
// Same rules as naming an attendee at registration (incl. date of birth and parent
// details on youth events), for exactly one place.
const [clean] = validateAttendees({ ...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.');
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('This ticket is already yours.');
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
const attendeeData = {
name: clean.name, email: clean.email, phone: clean.phone, userId: toUser.id,
dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone,
};
const result = await prisma.$transaction(async (tx) => {
// Re-check inside the transaction: a scan or another transfer may have just happened.
const fresh = await tx.ticket.findUnique({ where: { id: ticket.id }, include: { usages: { select: { id: true } } } });
if (!fresh || fresh.qrCode !== ticket.qrCode || fresh.usages.length > 0) throw transferError('This ticket changed while you were transferring it. Please try again.', 409);
let target, attendee, oldQrCode;
if (ticket.attendeeId) {
attendee = await tx.attendee.update({ where: { id: ticket.attendeeId }, data: attendeeData });
// The previous holder's answers and medical details aren't the new holder's.
await tx.formResponse.deleteMany({ where: { attendeeId: attendee.id } });
await tx.consentRequest.updateMany({ where: { attendeeId: attendee.id }, data: { medical: null, emergency: null, medicalPurgedAt: new Date() } });
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), holderUserId: toUser.id, emailSent: false } });
oldQrCode = ticket.qrCode;
} else {
const order = await tx.attendee.count({ where: { registrationId: ro.registration.id } });
attendee = await tx.attendee.create({
data: { registrationId: ro.registration.id, registrationOptionId: ro.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId, order, ...attendeeData },
});
if ((ticket.quantity || 1) > 1) {
// Split one place off the shared ticket; its code stays valid for the rest.
await tx.ticket.update({ where: { id: ticket.id }, data: { quantity: ticket.quantity - 1 } });
target = await tx.ticket.create({
data: { qrCode: uuidv4(), registrationOptionId: ro.id, userId: ticket.userId, eventId: ticket.eventId, quantity: 1, attendeeId: attendee.id, holderUserId: toUser.id },
});
oldQrCode = null;
} else {
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), attendeeId: attendee.id, holderUserId: toUser.id, emailSent: false } });
oldQrCode = ticket.qrCode;
}
}
const transfer = await tx.ticketTransfer.create({
data: { ticketId: target.id, fromHolderName, fromUserId: currentHolderId || null, toName: clean.name, toUserId: toUser.id, byUserId: actor.id, oldQrCode },
});
return { target, attendee, transfer };
});
return {
...result,
toUser,
accountCreated: created,
fromHolderName,
event,
registrationId: ro.registration.id,
needsConsent: !!event.requiresParentalConsent && isMinor(clean.dateOfBirth, event.startDate),
};
}
/** 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;
return prisma.ticketTransfer.findFirst({ where: { oldQrCode: String(qrCode) }, orderBy: { createdAt: 'desc' } });
}
function transferredMessage(transfer) {
const when = new Date(transfer.createdAt).toLocaleDateString('en-ZA', { day: 'numeric', month: 'short' });
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,
checkTransfer, requestTransfer, findRequestByToken, acceptTransfer, declineTransfer, cancelTransfer,
};