From 87ffe3b84b52230277474d44235f401237ce89f8 Mon Sep 17 00:00:00 2001 From: joshua Date: Mon, 19 Oct 2026 14:23:24 +0200 Subject: [PATCH] Add ticket transfer Payers and holders can hand a ticket to someone else before the event. The new holder gets a new QR code (and an inactive account with an activation link if they're new); the old code stops working and scans of it, online or in offline sync, say it was transferred. One place can be split off a shared group ticket. Transfers can be turned off per event (supervisors can always transfer from At the door), youth events need the new holder's own consent, and every transfer is audited. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 ++ FEATURES.md | 9 ++ backend/API_DOCUMENTATION.md | 22 +++ .../migration.sql | 36 +++++ backend/prisma/schema.prisma | 25 ++++ backend/src/controllers/eventController.js | 6 +- backend/src/controllers/ticketController.js | 80 +++++++++- backend/src/index.js | 6 + backend/src/routes/ticketRoutes.js | 6 +- backend/src/utils/accountMatch.js | 33 ++++- backend/src/utils/scanLocks.js | 9 +- backend/src/utils/ticketTransfer.js | 140 ++++++++++++++++++ backend/tests/ticketTransfer.test.js | 46 ++++++ .../app/dashboard/admin/audit-log/page.tsx | 2 + .../dashboard/supervisor/at-the-door/page.tsx | 26 +++- .../app/dashboard/supervisor/events/page.tsx | 12 ++ frontend/src/app/dashboard/user/page.tsx | 16 ++ .../components/shared/TransferTicketModal.tsx | 108 ++++++++++++++ frontend/src/content/help/dashboard-user.tsx | 5 +- .../content/help/supervisor-at-the-door.tsx | 2 +- info.md | 1 + 21 files changed, 596 insertions(+), 10 deletions(-) create mode 100644 backend/prisma/migrations/20261019140949_add_ticket_transfer/migration.sql create mode 100644 backend/src/utils/ticketTransfer.js create mode 100644 backend/tests/ticketTransfer.test.js create mode 100644 frontend/src/components/shared/TransferTicketModal.tsx 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.