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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 14:23:24 +02:00
co-authored by Claude Opus 5.5
parent 9a4bf51ad4
commit 87ffe3b84b
21 changed files with 596 additions and 10 deletions
@@ -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;
+25
View File
@@ -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