diff --git a/CHANGELOG.md b/CHANGELOG.md index 325d10d..b14d3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Ticket transfer. On My Events, "Transfer" hands a ticket to someone else before the event: enter their name and email or phone. + - The new holder gets the ticket by email/WhatsApp with a new QR code. If they're new, an inactive account is created and they get an activation link. + - The old QR code stops working, so screenshots of it won't scan. Scanning it says who the ticket was transferred to, and offline-mode sync flags it. + - For a ticket covering several people, one place is split off to the new holder. + - The payer stays responsible for the registration. +- Transfer rules: + - Only before the event starts, and only for tickets that haven't been scanned. + - It can be turned off per event (new "Ticket transfer" setting in the event wizard, on by default). Supervisors can always transfer, from At the door → Tickets. + - On youth events, the new holder's date of birth, and a parent's details for a minor, are needed, and a new consent request is sent. + - The previous holder's attendee form answers and consent medical details are removed. + - Every transfer is recorded in the audit log. +- New endpoints `POST /api/tickets/:id/transfer` and `GET /api/tickets/:id/transfers`. + - Requires the `add_ticket_transfer` database migration. + ## [1.17.0] - 2026-10-19 ### Added diff --git a/FEATURES.md b/FEATURES.md index e9bc922..d45c5f0 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -266,6 +266,15 @@ logout controls. Disconnected sessions are recovered automatically. - Registration detail: itemised bill (early-bird lines grouped), payments, **Invoice** and **Receipt** buttons, edit, cancel, **Make payment**. - Upcoming events and tickets side by side, with bulk ticket email/WhatsApp. +- **Ticket transfer** — hand a ticket to someone else before the event (name plus + email/phone). They get a new ticket and QR code, and an inactive account with an + activation link if they're new. The old code stops working, and scanning it says it + was transferred, both online and in offline-mode sync reports. One place can be split + off a ticket covering several people. Billing stays with the payer. It's not allowed + once a ticket has been scanned, and it can be turned off per event (supervisors can + always transfer, from At the door → Tickets). On youth events the new holder needs + their own parental consent, and the previous holder's form answers are removed. + Every transfer is in the audit log. - **Payment history** (`/dashboard/user/payments`) — paginated, with filters by date, method and payment/refund. - **Donate** page. diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index cd60d7a..c8d514b 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -22,6 +22,7 @@ Tokens are issued by `POST /api/users/login`. - [Parental consent](#parental-consent) - [Waitlist](#waitlist) - [Offline scanning](#offline-scanning) +- [Ticket transfer](#ticket-transfer) - [Payments](#payments) - [Tickets](#tickets) - [Broadcasts (Email)](#broadcasts-email) @@ -279,6 +280,27 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`: --- +## Ticket transfer + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }` (date of birth, plus parent details for a minor, on youth events) | +| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history | + +Rules: +- Only before the event starts, and only for tickets that have never been scanned. +- `Event.allowTicketTransfer` turns it off for non-supervisors. + +What a transfer does: +- The new holder becomes the ticket's attendee and gets a new QR code. A shared ticket for several places has one place split off into its own ticket. +- An account is matched by email/phone, or created inactive with an activation link. +- The previous holder's attendee form answers are deleted, and the medical details on their consent are cleared. A minor on a youth event gets a new consent request. + +Old QR codes: +- `scan-preview` and `scan` return `410 { code: "TICKET_TRANSFERRED" }`. +- Offline sync reports them as `transferred`. +- Each transfer is audited as `ticket_transferred`. + ## Offline scanning | Method | Path | Auth | Description | diff --git a/backend/prisma/migrations/20261019140949_add_ticket_transfer/migration.sql b/backend/prisma/migrations/20261019140949_add_ticket_transfer/migration.sql new file mode 100644 index 0000000..39d7657 --- /dev/null +++ b/backend/prisma/migrations/20261019140949_add_ticket_transfer/migration.sql @@ -0,0 +1,36 @@ +-- AlterEnum +ALTER TYPE "AdminAuditAction" ADD VALUE 'ticket_transferred'; + +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "allowTicketTransfer" BOOLEAN NOT NULL DEFAULT true; + +-- CreateTable +CREATE TABLE "TicketTransfer" ( + "id" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "fromHolderName" TEXT NOT NULL, + "fromUserId" TEXT, + "toName" TEXT NOT NULL, + "toUserId" TEXT, + "byUserId" TEXT, + "oldQrCode" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TicketTransfer_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TicketTransfer_ticketId_idx" ON "TicketTransfer"("ticketId"); + +-- CreateIndex +CREATE INDEX "TicketTransfer_oldQrCode_idx" ON "TicketTransfer"("oldQrCode"); + +-- AddForeignKey +ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_byUserId_fkey" FOREIGN KEY ("byUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ac058ec..ab17864 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -106,6 +106,8 @@ model User { heldTickets Ticket[] @relation("TicketHolder") waitlistEntries WaitlistEntry[] scanLocks ScanLock[] @relation("ScanLockOwner") + transfersTo TicketTransfer[] @relation("TransferRecipient") + transfersBy TicketTransfer[] @relation("TransferActor") scanLocksForced ScanLock[] @relation("ScanLockForcedBy") syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer") @@ -141,6 +143,7 @@ model Event { // them for waitlistOfferHours. waitlistPartialOffers: offer what's free even if it's // fewer places than asked for (otherwise skip to the next person who fits). waitlistEnabled Boolean @default(false) + allowTicketTransfer Boolean @default(true) waitlistOfferHours Int @default(24) waitlistPartialOffers Boolean @default(true) contactName String? @@ -365,6 +368,7 @@ model Ticket { user User @relation(fields: [userId], references: [id], onDelete: Restrict) event Event @relation(fields: [eventId], references: [id], onDelete: Restrict) usages TicketUsage[] + transfers TicketTransfer[] @@index([holderUserId]) @@index([eventId]) @@ -476,6 +480,7 @@ enum AdminAuditAction { scan_lock_released scan_lock_force_released offline_sync + ticket_transferred } // Append-only audit trail for admin/supervisor-initiated actions with money or @@ -990,6 +995,26 @@ enum TicketDelivery { // One named person on a group registration, for one main-ticket place. Gets their // own quantity-1 ticket and their own attendee form response. +/// A ticket handed to someone else. oldQrCode is the code that stopped working (null +/// when one place was split off a shared group ticket, whose code stays valid). +model TicketTransfer { + id String @id @default(uuid()) + ticketId String + fromHolderName String + fromUserId String? + toName String + toUserId String? + byUserId String? + oldQrCode String? + createdAt DateTime @default(now()) + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + toUser User? @relation("TransferRecipient", fields: [toUserId], references: [id], onDelete: SetNull) + byUser User? @relation("TransferActor", fields: [byUserId], references: [id], onDelete: SetNull) + + @@index([ticketId]) + @@index([oldQrCode]) +} + model Attendee { id String @id @default(uuid()) registrationId String diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index 1daf850..b48a0b9 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) { // @access Private/Admin const createEvent = async (req, res) => { try { - const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body; + const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, contactName, contactPhone, contactEmail, location } = req.body; const data = { id: uuidv4(), @@ -70,6 +70,7 @@ const createEvent = async (req, res) => { consentCollectEmergency: consentCollectEmergency === true, consentCollectPhoto: consentCollectPhoto === true, waitlistEnabled: waitlistEnabled === true, + allowTicketTransfer: allowTicketTransfer !== false, waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)), waitlistPartialOffers: waitlistPartialOffers !== false, contactName: contactName || null, @@ -536,7 +537,7 @@ const updateEvent = async (req, res) => { ip: getClientIp(req), }); - const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body; + const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, contactName, contactPhone, contactEmail, location } = req.body; const data = { title: title || event.title, @@ -559,6 +560,7 @@ const updateEvent = async (req, res) => { consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency, consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto, waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled, + allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer, waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours, waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers, // Editing the consent text starts a new version; parents who already agreed keep diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js index 988f10f..26e723d 100644 --- a/backend/src/controllers/ticketController.js +++ b/backend/src/controllers/ticketController.js @@ -287,6 +287,9 @@ const getScanPreview = async (req, res) => { `; 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'); } @@ -351,6 +354,9 @@ const scanTicket = async (req, res) => { `; 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'); } @@ -482,6 +488,7 @@ const getTicketsByEvent = async (req, res) => { registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } }, attendee: { select: { id: true, name: true, email: true, phone: true } }, + event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true } }, usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } } }, orderBy: { createdAt: 'desc' }, @@ -691,6 +698,75 @@ 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? } +// @access Private (payer or holder; supervisors always) +const transferTicketHandler = async (req, res) => { + try { + const { transferTicket } = require('../utils/ticketTransfer'); + const { name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone } = req.body || {}; + const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone }); + + 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 }, + ip: getClientIp(req), + }); + + // Tell the new holder: activation link for a new account, their ticket, and + // anything they need to do themselves (consent, attendee form). + if (r.accountCreated) { + const { sendActivationLink } = require('./userController'); + sendActivationLink(r.toUser); + } + const a = r.attendee; + const hasEmail = !!(a.email && a.email.includes('@')); + const recipient = { id: r.toUser.id, name: a.name, email: hasEmail ? a.email : null, phoneNumber: a.phone || null, notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp' }; + const silent = { status: () => silent, json: () => silent }; + emailTicketsInternal({ user: { id: r.target.userId }, body: { ticketIds: [r.target.id] }, _noSplit: true }, silent, recipient) + .catch(e => console.error('[transfer] ticket delivery failed:', e?.message || e)); + if (r.needsConsent) { + const { createConsentRequest } = require('../utils/consent'); + createConsentRequest(a, r.event).catch(e => console.error('[transfer] consent request failed:', e?.message || e)); + } + const form = await prisma.eventForm.findUnique({ where: { eventId: r.event.id }, include: { fields: { select: { id: true } } } }); + if (form && form.fields.length > 0) { + const { sendAttendeeFormLinkMessage } = require('../utils/notifications'); + const full = await prisma.attendee.findUnique({ where: { id: a.id }, include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } } }); + sendAttendeeFormLinkMessage(full).catch(() => {}); + } + + 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.`, + ticketId: r.target.id, + }); + } catch (error) { + res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) }); + } +}; + +// @desc Transfer history of a ticket +// @route GET /api/tickets/:id/transfers +// @access Private (payer or holder; staff) +const getTicketTransfers = async (req, res) => { + try { + const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id }, select: { userId: true, holderUserId: true } }); + if (!ticket) { res.status(404); throw new Error('Ticket not found'); } + const staffRole = ['admin', 'supervisor', 'staff'].includes(req.user.role); + if (!staffRole && ticket.userId !== req.user.id && ticket.holderUserId !== req.user.id) { res.status(403); throw new Error('Not authorized'); } + const transfers = await prisma.ticketTransfer.findMany({ + where: { ticketId: req.params.id }, + select: { id: true, fromHolderName: true, toName: true, createdAt: true, byUser: { select: { name: true } } }, + orderBy: { createdAt: 'desc' }, + }); + res.json(transfers); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + // Internal helper used by both emailTickets and sendTicketsTo async function emailTicketsInternal(req, res, userOverride) { const { ticketIds, registrationId } = req.body; @@ -888,5 +964,7 @@ module.exports = { emailTickets, sendTicketsTo, getRecentScans, - getScanStats + getScanStats, + transferTicketHandler, + getTicketTransfers }; \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index 6054093..7dbbe3f 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -635,6 +635,12 @@ app.get('/docs', async (req, res) => { ]}, ]}, + { title: 'Ticket transfer', base: '/api/tickets', endpoints: [ + { 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.' }}]}, + { method:'GET', path:'/api/tickets/:id/transfers', auth:'payer, holder or staff+', desc:'Transfer history of a ticket' }, + ]}, { title: 'Offline scanning', base: '/api/scan-locks', endpoints: [ { method:'GET', path:'/api/scan-locks', auth:'staff+', desc:'Active offline locks for an event (?eventId=)' }, { method:'POST', path:'/api/scan-locks', auth:'staff+', desc:'Lock a section (or the whole event) to this device for offline scanning', diff --git a/backend/src/routes/ticketRoutes.js b/backend/src/routes/ticketRoutes.js index 1a454e1..180f0ed 100644 --- a/backend/src/routes/ticketRoutes.js +++ b/backend/src/routes/ticketRoutes.js @@ -13,7 +13,9 @@ const { emailTickets, sendTicketsTo, getRecentScans, - getScanStats + getScanStats, + transferTicketHandler, + getTicketTransfers } = require('../controllers/ticketController'); const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware'); @@ -21,6 +23,8 @@ const { protect, admin, supervisor, staff } = require('../middleware/authMiddlew router.get('/mytickets', protect, getUserTickets); router.get('/:id', protect, getTicketById); router.post('/email', protect, emailTickets); +router.post('/:id/transfer', protect, transferTicketHandler); +router.get('/:id/transfers', protect, getTicketTransfers); // Staff routes router.post('/send-to', protect, staff, sendTicketsTo); diff --git a/backend/src/utils/accountMatch.js b/backend/src/utils/accountMatch.js index aa9d968..303c176 100644 --- a/backend/src/utils/accountMatch.js +++ b/backend/src/utils/accountMatch.js @@ -33,4 +33,35 @@ async function findAccountByContact({ email, phone: rawPhone }, db = prisma) { return { phone, emailUser, phoneUser, conflict, user: conflict ? null : (emailUser || phoneUser || null) }; } -module.exports = { findAccountByContact, hasValidEmail }; +/** + * The account for these contact details, or a new inactive one (same as guest + * registration: a placeholder email for phone-only people, a random password, and an + * activation link sent by the caller when `created` is true). + */ +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; + } + if (match.user) return { user: match.user, created: false }; + const { v4: uuidv4 } = require('uuid'); + const { hashPassword } = require('../config/auth'); + const validEmail = hasValidEmail(email); + const user = await db.user.create({ + data: { + id: uuidv4(), + name, + email: validEmail ? String(email).trim() : `guest+${uuidv4().slice(0, 8)}@guest.local`, + password: await hashPassword(uuidv4()), + phoneNumber: match.phone || null, + isActive: false, + notificationPreference: validEmail && match.phone ? 'both' : validEmail ? 'email' : 'whatsapp', + updatedAt: new Date(), + }, + }); + return { user, created: true }; +} + +module.exports = { findAccountByContact, findOrCreateAccount, hasValidEmail }; diff --git a/backend/src/utils/scanLocks.js b/backend/src/utils/scanLocks.js index 80079dc..d1de2a4 100644 --- a/backend/src/utils/scanLocks.js +++ b/backend/src/utils/scanLocks.js @@ -52,6 +52,7 @@ function lockCovers(lock, { sectionId = null, optionId }) { */ function judgeSyncedScan(ticket, qty) { if (!ticket) return { redeem: 0, conflict: 'not_found' }; + if (ticket.transferred) return { redeem: 0, conflict: 'transferred' }; if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' }; if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' }; if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' }; @@ -61,7 +62,8 @@ function judgeSyncedScan(ticket, qty) { } const CONFLICT_TEXT = { - not_found: 'Ticket not found (possibly transferred or reissued)', + not_found: 'Ticket not found', + transferred: 'Old QR code of a ticket that was transferred to someone else', cancelled: 'Registration was cancelled', wrong_scope: 'Ticket is not valid for this section', already_used: 'Ticket had already been fully used', @@ -172,7 +174,10 @@ async function syncScans(lock, scans) { include: { usages: true, registrationOption: { include: { registration: { select: { status: true } }, eventOption: { select: { isMainTicket: true } } } } }, }); let state = null; - if (ticket && ticket.eventId === lock.eventId) { + if (!ticket) { + const { findTransferByOldCode } = require('./ticketTransfer'); + if (await findTransferByOldCode(scan.qrCode)) state = { transferred: true }; + } else if (ticket.eventId === lock.eventId) { const redeemed = ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0); const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id); state = { diff --git a/backend/src/utils/ticketTransfer.js b/backend/src/utils/ticketTransfer.js new file mode 100644 index 0000000..e1dad52 --- /dev/null +++ b/backend/src/utils/ticketTransfer.js @@ -0,0 +1,140 @@ +/** + * Ticket transfer — handing a ticket to someone else before the event. + * + * - 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']; + +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(`${clean.name} already holds this ticket.`); + + 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, 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), + }; +} + +/** 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 }; diff --git a/backend/tests/ticketTransfer.test.js b/backend/tests/ticketTransfer.test.js new file mode 100644 index 0000000..0e0c894 --- /dev/null +++ b/backend/tests/ticketTransfer.test.js @@ -0,0 +1,46 @@ +const { transferBlocker } = require('../src/utils/ticketTransfer'); +const { judgeSyncedScan } = require('../src/utils/scanLocks'); + +describe('transferBlocker', () => { + const now = new Date('2026-10-01T10:00:00Z'); + const ticket = { + userId: 'payer', holderUserId: 'holder', usages: [], registrationStatus: 'paid', + event: { startDate: '2026-10-05T08:00:00Z', allowTicketTransfer: true }, + }; + const user = (id, role = 'user') => ({ id, role }); + + test('the payer and the holder can transfer', () => { + expect(transferBlocker(ticket, user('payer'), now)).toBeNull(); + expect(transferBlocker(ticket, user('holder'), now)).toBeNull(); + }); + + test('anyone else needs to be a supervisor', () => { + expect(transferBlocker(ticket, user('someone'), now).status).toBe(403); + expect(transferBlocker(ticket, user('staffer', 'staff'), now).status).toBe(403); + expect(transferBlocker(ticket, user('sup', 'supervisor'), now)).toBeNull(); + }); + + test('not once the event has started', () => { + expect(transferBlocker({ ...ticket, event: { ...ticket.event, startDate: '2026-10-01T09:00:00Z' } }, user('sup', 'supervisor'), now).message).toMatch(/before the event starts/); + }); + + test('not once any of it has been scanned', () => { + expect(transferBlocker({ ...ticket, usages: [{ id: 'u1' }] }, user('payer'), now).message).toMatch(/already been used/); + }); + + test('not for a cancelled registration', () => { + expect(transferBlocker({ ...ticket, registrationStatus: 'cancelled' }, user('payer'), now)).not.toBeNull(); + }); + + test('turned off per event, except for supervisors', () => { + const off = { ...ticket, event: { ...ticket.event, allowTicketTransfer: false } }; + expect(transferBlocker(off, user('payer'), now).message).toMatch(/turned off/); + expect(transferBlocker(off, user('admin', 'admin'), now)).toBeNull(); + }); +}); + +describe('offline sync of a transferred code', () => { + test('is flagged and not recorded', () => { + expect(judgeSyncedScan({ transferred: true }, 1)).toEqual({ redeem: 0, conflict: 'transferred' }); + }); +}); diff --git a/frontend/src/app/dashboard/admin/audit-log/page.tsx b/frontend/src/app/dashboard/admin/audit-log/page.tsx index d745c38..78e7dab 100644 --- a/frontend/src/app/dashboard/admin/audit-log/page.tsx +++ b/frontend/src/app/dashboard/admin/audit-log/page.tsx @@ -29,6 +29,7 @@ const ACTIONS = [ "scan_lock_released", "scan_lock_force_released", "offline_sync", + "ticket_transferred", ] as const; const ACTION_LABELS: Record = { @@ -52,6 +53,7 @@ const ACTION_LABELS: Record = { scan_lock_released: "Offline scanning ended", scan_lock_force_released: "Offline lock force-released", offline_sync: "Offline scans synced", + ticket_transferred: "Ticket transferred", }; interface AuditLogEntry { diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx index aae94e2..bb9d56b 100644 --- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api"; import { scoreUser } from "@/lib/fuzzyMatch"; import { useDismissingState } from "@/hooks/useDismissingState"; import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField"; +import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal"; import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor"; import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react"; @@ -869,6 +870,8 @@ function DoorTicketsPanel({ token, eventId }: any) { const [allTickets, setAllTickets] = useState([]); const [loading, setLoading] = useState(false); const [sendTarget, setSendTarget] = useState(null); + const [transferTarget, setTransferTarget] = useState(null); + const [notice, setNotice] = useState(null); // Load all tickets for the event once useEffect(() => { @@ -1000,6 +1003,7 @@ function DoorTicketsPanel({ token, eventId }: any) { )} + {notice &&
{notice}
}
{tickets.map(t => (
- {t.user?.name || "Guest"} + {t.attendee?.name || t.user?.name || "Guest"}
+ {t.attendee?.name && t.user?.name && t.attendee.name !== t.user.name && ( +
Booked by {t.user.name}
+ )}
{ticketLabel(t)}
@@ -1028,6 +1035,14 @@ function DoorTicketsPanel({ token, eventId }: any) { > Send + {canOfferTransfer(t, t.event, true) && ( + + )}
))} @@ -1039,6 +1054,15 @@ function DoorTicketsPanel({ token, eventId }: any) { )}
+ {transferTarget && ( + setTransferTarget(null)} + onDone={(msg) => { setTransferTarget(null); setNotice(msg); load(); }} + /> + )} {sendTarget && ( ({ @@ -784,6 +785,7 @@ const blankDraft = (location = ""): EventDraft => ({ requiresParentalConsent: false, consentText: "", consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true, waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true, + allowTicketTransfer: true, }); const blankOptions = (): OptionDraft[] => [ @@ -822,6 +824,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { requiresParentalConsent: !!ev.requiresParentalConsent, consentText: ev.consentText || "", consentCollectMedical: !!ev.consentCollectMedical, consentCollectEmergency: !!ev.consentCollectEmergency, consentCollectPhoto: !!ev.consentCollectPhoto, waitlistEnabled: !!ev.waitlistEnabled, waitlistOfferHours: String(ev.waitlistOfferHours ?? 24), waitlistPartialOffers: ev.waitlistPartialOffers !== false, + allowTicketTransfer: ev.allowTicketTransfer !== false, contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "", location: ev.location || "", } : blankDraft(settings.org_address || "")); @@ -1102,6 +1105,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { consentCollectEmergency: draft.consentCollectEmergency, consentCollectPhoto: draft.consentCollectPhoto, waitlistEnabled: draft.waitlistEnabled, + allowTicketTransfer: draft.allowTicketTransfer, waitlistOfferHours: parseInt(draft.waitlistOfferHours, 10) || 24, waitlistPartialOffers: draft.waitlistPartialOffers, contactName: draft.contactName || undefined, @@ -1391,6 +1395,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { )} +
+
Ticket transfer
+ +
+
Youth event
); @@ -1012,6 +1018,16 @@ export default function UserDashboardPage() { + {transferTarget && token && ( + setTransferTarget(null)} + onDone={(msg) => { setTransferTarget(null); setInfo(msg); reloadAfterAttendees(); }} + /> + )} + {activeReg && (
{ setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
e.stopPropagation()}> diff --git a/frontend/src/components/shared/TransferTicketModal.tsx b/frontend/src/components/shared/TransferTicketModal.tsx new file mode 100644 index 0000000..640ec01 --- /dev/null +++ b/frontend/src/components/shared/TransferTicketModal.tsx @@ -0,0 +1,108 @@ +"use client"; + +import React, { useState } from "react"; +import { ArrowRightLeft } from "lucide-react"; +import { apiFetch } from "@/lib/api"; +import { isMinorOn } from "@/components/shared/AttendeeNamesEditor"; + +/** Whether to offer "Transfer" on a ticket (the server checks the rules again). */ +export function canOfferTransfer(ticket: any, event: any, isSupervisor = false): boolean { + if (!ticket || !event) return false; + if ((ticket.usages || []).length > 0 || ticket.isUsed) return false; + if (event.startDate && new Date(event.startDate) <= new Date()) return false; + return isSupervisor || event.allowTicketTransfer !== false; +} + +/** + * Hand a ticket to someone else: their name and email/phone (plus date of birth and a + * parent's details on youth events). They get a new QR code; the old one stops working. + */ +export function TransferTicketModal({ ticket, event, token, onClose, onDone }: { + ticket: any; + event: any; + token: string; + onClose: () => void; + onDone: (message: string) => void; +}) { + const youth = !!event?.requiresParentalConsent; + const [f, setF] = useState({ name: "", email: "", phone: "", dateOfBirth: "", parentName: "", parentEmail: "", parentPhone: "" }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const minor = youth && isMinorOn(f.dateOfBirth, event?.startDate); + const places = ticket.quantity || 1; + const set = (k: keyof typeof f) => (e: React.ChangeEvent) => setF(prev => ({ ...prev, [k]: e.target.value })); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + setError(null); + try { + const body: Record = { name: f.name, email: f.email, phone: f.phone }; + if (youth) Object.assign(body, { dateOfBirth: f.dateOfBirth, parentName: f.parentName, parentEmail: f.parentEmail, parentPhone: f.parentPhone }); + const res = await apiFetch<{ message: string }>(`/api/tickets/${ticket.id}/transfer`, { method: "POST", authToken: token, body }); + onDone(res.message); + } catch (err: any) { + setError(err?.message || "Couldn't transfer the ticket"); + } finally { + setBusy(false); + } + }; + + const input = "w-full border rounded-lg px-3 py-2 text-sm"; + return ( +
+
e.stopPropagation()}> +
+ +

Transfer ticket

+
+

+ {places > 1 + ? `This ticket is for ${places} people. One place moves to the new holder on their own ticket; the other ${places - 1} stay on this one.` + : "The new holder gets their own ticket with a new QR code. The current QR code (including any screenshots of it) will stop working."} + {" "}The booking and payment stay with the person who booked. +

+
+ + +
+
+
+ + +
+
+ + +
+
+

An email or phone number is needed to send them the ticket. If they don't have an account yet, one is created and they get a link to activate it.

+ {youth && ( +
+
+ + +
+ {minor && ( + <> +

They're under 18, so a parent or guardian will be asked for consent before they can check in.

+ +
+ + +
+ + )} +
+ )} + {error &&
{error}
} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/content/help/dashboard-user.tsx b/frontend/src/content/help/dashboard-user.tsx index 00075e8..2336fe5 100644 --- a/frontend/src/content/help/dashboard-user.tsx +++ b/frontend/src/content/help/dashboard-user.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users } from "lucide-react"; +import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users, ArrowRightLeft } from "lucide-react"; import { GuideItem } from "@/components/shared/GuideItem"; import type { HelpContent } from "./types"; @@ -60,6 +60,9 @@ export const dashboardUserHelpContent: HelpContent = { Get your selected tickets onto your phone or paper before the event — as a printed page, an email, or a WhatsApp message. + + Can't make it? Tap "Transfer" to give your ticket to someone else before the event. Enter their name and email or phone, and they get a new ticket with a new QR code. The old code (and any screenshots of it) stops working. For a ticket covering several people, one place moves to them. You stay responsible for the booking and payment. A ticket can't be transferred once it has been scanned. +
), diff --git a/frontend/src/content/help/supervisor-at-the-door.tsx b/frontend/src/content/help/supervisor-at-the-door.tsx index 0efc0ae..3b2021e 100644 --- a/frontend/src/content/help/supervisor-at-the-door.tsx +++ b/frontend/src/content/help/supervisor-at-the-door.tsx @@ -25,7 +25,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = { Per ticket-type totals with a stepper to check attendees in against the scan record. - Browse all tickets for the event; print or send (email/WhatsApp) any of them individually. + Browse all tickets for the event; print or send (email/WhatsApp) any of them individually, or transfer one to someone else before the event starts (supervisors can transfer even when transfers are turned off for the event). Scanning a transferred ticket's old QR code says who it went to. Issue a refund against an existing payment for the selected registration. diff --git a/info.md b/info.md index 7e34fad..3b97b6a 100644 --- a/info.md +++ b/info.md @@ -30,6 +30,7 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing ### For staff on the day - **QR code scanner** — check people in by scanning their ticket with any phone camera. A single ticket can cover a whole family and be checked in a few people at a time. +- **Ticket transfer** — can't make it? Pass your ticket on to a friend; they get a fresh QR code and the old one stops working. - **Works without Wi-Fi** — lock a check-in point to a phone and keep scanning with no signal; scans sync automatically when the connection is back. - **Scan points** — set up separate check-in points (e.g. main hall, dinner) that only accept the right tickets. - **At-the-door desk** — search by name, phone, or ticket number; register walk-ins; take cash, card, or EFT payments; check people in; and print or resend tickets on the spot.