Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9588aeba8 | ||
|
|
83df90b531 | ||
|
|
87ffe3b84b | ||
|
|
9a4bf51ad4 | ||
|
|
d84663f901 | ||
|
|
be621c49a2 |
@@ -7,6 +7,46 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.18.0] - 2026-10-19
|
||||
|
||||
### 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
|
||||
|
||||
- Offline ticket scanning. While online, staff pick an event and section on the scanner and tap "Enable offline mode". The section (or the whole event) is locked to their device and its tickets are downloaded to the device.
|
||||
- With no connection, scans are checked against that list and queued on the device.
|
||||
- The panel shows when the list was downloaded and how many scans are waiting.
|
||||
- Queued scans sync automatically when the device is back online (or with "Sync now"), and check-in messages go out then.
|
||||
- "Release section" ends offline mode and deletes the list from the device.
|
||||
- Only one device can hold a section at a time (a whole-event lock clashes with every section). While a section is locked, other devices see who holds it and can't scan it, online or offline, so a ticket can never be redeemed twice by two offline devices.
|
||||
- Syncing is idempotent and never redeems more than is left on a ticket. Anything that needs a person to look goes into a sync report: tickets not found, cancelled registrations, used-up tickets, scans outside the locked section, and check-ins without consent.
|
||||
- Supervisor → Offline scanning page: active locks with force release (for a lost or flat phone; its scans still sync if it reconnects, and are flagged), and sync reports to review. Locks, releases, force-releases and syncs are recorded in the audit log.
|
||||
- The scanner is installable as an app (web app manifest), and a service worker caches the scanner page and its scripts so it opens with no connection.
|
||||
- The app now stays signed in with the last confirmed profile when it's reloaded without a connection, instead of sending the user to the login page.
|
||||
- New `/api/scan-locks` endpoints.
|
||||
- Requires the `add_offline_scanning` database migration.
|
||||
|
||||
### Changed
|
||||
|
||||
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and checks it on the server; this was previously only checked in the browser.
|
||||
- It now refuses (423) scans in a section that another device has locked for offline scanning.
|
||||
|
||||
## [1.16.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
+19
-1
@@ -200,6 +200,13 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were
|
||||
checked in and how many remain.
|
||||
- **Scan stats** — recent scans and per-event scan counts.
|
||||
- **Offline scanning** — while online, a staff member locks an event section (or the
|
||||
whole event) to their device and downloads its tickets. They can then keep scanning
|
||||
with no connection; scans are queued on the device and sync automatically when it's
|
||||
back online (never redeeming more than is left). Only one device can hold a section,
|
||||
and while it's locked nobody else can scan it, online or offline. Problems found
|
||||
while syncing go into a report for a supervisor. Supervisors can force-release a
|
||||
lost device. The scanner can be added to the home screen (PWA) and opens offline.
|
||||
- **Printing** — A4 sheets (8 tickets per page) and A6 single tickets.
|
||||
- **Resend** — send tickets to a different email/phone at the door.
|
||||
|
||||
@@ -259,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.
|
||||
@@ -299,6 +315,8 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
Reconcile (Yoco transactions), and Payment Link.
|
||||
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
|
||||
discount codes, and see who used each one.
|
||||
- **Offline scanning** (`/dashboard/supervisor/offline-scans`) — devices holding
|
||||
offline locks (with force release) and sync reports to review.
|
||||
- **Waitlist** (`/dashboard/supervisor/waitlist`) — the queue per event: offer a
|
||||
place now, move people up or down, or remove them.
|
||||
- **Parental consent** (`/dashboard/supervisor/consent`) — consent status per
|
||||
@@ -469,5 +487,5 @@ falling back to a general guide.
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -21,6 +21,8 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Coupons](#coupons)
|
||||
- [Parental consent](#parental-consent)
|
||||
- [Waitlist](#waitlist)
|
||||
- [Offline scanning](#offline-scanning)
|
||||
- [Ticket transfer](#ticket-transfer)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -278,6 +280,50 @@ 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 |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/scan-locks?eventId=` | staff+ | Active offline locks for an event |
|
||||
| POST | `/api/scan-locks` | staff+ | Lock `{ eventId, sectionId?, deviceId, deviceName? }` to this device (409 if the scope is held elsewhere) |
|
||||
| GET | `/api/scan-locks/:id/snapshot?deviceId=` | staff+ | Tickets valid in the lock's scope (lock holder only) |
|
||||
| POST | `/api/scan-locks/:id/sync` | staff+ | `{ deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }` — idempotent; returns accepted, duplicates, conflicts |
|
||||
| DELETE | `/api/scan-locks/:id` | staff+ | Release (`{ deviceId }`, lock holder only) |
|
||||
| POST | `/api/scan-locks/:id/force-release` | supervisor+ | Release a lost device's lock |
|
||||
| GET | `/api/scan-locks/reports?eventId=&unreviewed=` | supervisor+ | Sync reports |
|
||||
| POST | `/api/scan-locks/reports/:id/review` | supervisor+ | Mark a report reviewed |
|
||||
|
||||
How locks work:
|
||||
- One device per scope. A whole-event lock (`sectionId` null) clashes with every section lock on that event.
|
||||
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and `deviceId`. It enforces the section on the server, and returns `423 { code: "SCAN_LOCKED" }` when another device holds a lock covering the ticket.
|
||||
|
||||
How syncing works:
|
||||
- Synced scans never redeem more than is left on a ticket.
|
||||
- Conflicts are recorded in the sync report: `not_found`, `cancelled`, `wrong_scope`, `already_used`, `partly_used` and `consent`.
|
||||
- Check-in messages are sent after the sync.
|
||||
- Locks, releases, force-releases and syncs are written to the admin audit log.
|
||||
|
||||
## Waitlist
|
||||
|
||||
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.18.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.18.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.18.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_acquired';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_released';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_force_released';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'offline_sync';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TicketUsage" ADD COLUMN "clientScanId" TEXT,
|
||||
ADD COLUMN "offline" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "scanLockId" TEXT,
|
||||
ADD COLUMN "syncedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ScanLock" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"sectionId" TEXT,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
"deviceName" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"lockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"snapshotAt" TIMESTAMP(3),
|
||||
"lastSyncAt" TIMESTAMP(3),
|
||||
"releasedAt" TIMESTAMP(3),
|
||||
"forceReleasedById" TEXT,
|
||||
|
||||
CONSTRAINT "ScanLock_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OfflineSyncReport" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scanLockId" TEXT NOT NULL,
|
||||
"accepted" INTEGER NOT NULL DEFAULT 0,
|
||||
"duplicates" INTEGER NOT NULL DEFAULT 0,
|
||||
"conflicts" JSONB NOT NULL DEFAULT '[]',
|
||||
"afterForceRelease" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"reviewedAt" TIMESTAMP(3),
|
||||
"reviewedById" TEXT,
|
||||
|
||||
CONSTRAINT "OfflineSyncReport_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_eventId_releasedAt_idx" ON "ScanLock"("eventId", "releasedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_userId_idx" ON "ScanLock"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_sectionId_idx" ON "ScanLock"("sectionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ScanLock_forceReleasedById_idx" ON "ScanLock"("forceReleasedById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OfflineSyncReport_scanLockId_idx" ON "OfflineSyncReport"("scanLockId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OfflineSyncReport_reviewedById_idx" ON "OfflineSyncReport"("reviewedById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TicketUsage_clientScanId_key" ON "TicketUsage"("clientScanId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_forceReleasedById_fkey" FOREIGN KEY ("forceReleasedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -103,8 +103,13 @@ model User {
|
||||
couponsApplied CouponRedemption[] @relation("CouponRedemptionAppliedBy")
|
||||
|
||||
attendeeProfiles Attendee[]
|
||||
heldTickets Ticket[] @relation("TicketHolder")
|
||||
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")
|
||||
|
||||
twoFactorChallenges TwoFactorChallenge[]
|
||||
trustedDevices TrustedDevice[]
|
||||
@@ -138,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?
|
||||
@@ -175,6 +181,7 @@ model Event {
|
||||
coupons CouponEvent[]
|
||||
consentRequests ConsentRequest[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
scanLocks ScanLock[]
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@ -361,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])
|
||||
@@ -370,13 +378,20 @@ model Ticket {
|
||||
}
|
||||
|
||||
model TicketUsage {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
ticketId String
|
||||
scannedById String
|
||||
scannedAt DateTime @default(now())
|
||||
quantityRedeemed Int @default(1)
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
|
||||
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
|
||||
scannedAt DateTime @default(now())
|
||||
quantityRedeemed Int @default(1)
|
||||
// Offline scanning: the device's id for the scan (makes syncing idempotent), and
|
||||
// the offline lock it was made under.
|
||||
clientScanId String? @unique
|
||||
offline Boolean @default(false)
|
||||
scanLockId String?
|
||||
syncedAt DateTime?
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
|
||||
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
|
||||
scanLock ScanLock? @relation(fields: [scanLockId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([ticketId])
|
||||
@@index([scannedAt])
|
||||
@@ -461,6 +476,11 @@ enum AdminAuditAction {
|
||||
consent_override
|
||||
consent_rerequested
|
||||
medical_list_viewed
|
||||
scan_lock_acquired
|
||||
scan_lock_released
|
||||
scan_lock_force_released
|
||||
offline_sync
|
||||
ticket_transferred
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -601,6 +621,7 @@ model Section {
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
allowedOptions SectionOption[]
|
||||
scanLocks ScanLock[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -974,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
|
||||
@@ -1085,3 +1126,51 @@ model WaitlistEntry {
|
||||
@@index([userId])
|
||||
@@index([registrationId])
|
||||
}
|
||||
|
||||
// ─── Offline scanning ─────────────────────────────────────────────────────────
|
||||
|
||||
// Locks a section (or the whole event, sectionId null) to one device for offline
|
||||
// scanning, so two devices can never redeem the same ticket while offline.
|
||||
// One active (releasedAt null) lock per scope — enforced in utils/scanLocks.js.
|
||||
model ScanLock {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
sectionId String?
|
||||
deviceId String
|
||||
deviceName String?
|
||||
userId String
|
||||
lockedAt DateTime @default(now())
|
||||
snapshotAt DateTime?
|
||||
lastSyncAt DateTime?
|
||||
releasedAt DateTime?
|
||||
forceReleasedById String?
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||
user User @relation("ScanLockOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
forceReleasedBy User? @relation("ScanLockForcedBy", fields: [forceReleasedById], references: [id], onDelete: SetNull)
|
||||
usages TicketUsage[]
|
||||
syncReports OfflineSyncReport[]
|
||||
|
||||
@@index([eventId, releasedAt])
|
||||
@@index([userId])
|
||||
@@index([sectionId])
|
||||
@@index([forceReleasedById])
|
||||
}
|
||||
|
||||
// One sync of queued offline scans; `conflicts` lists scans that needed a person to look.
|
||||
model OfflineSyncReport {
|
||||
id String @id @default(uuid())
|
||||
scanLockId String
|
||||
accepted Int @default(0)
|
||||
duplicates Int @default(0)
|
||||
conflicts Json @default("[]")
|
||||
afterForceRelease Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
reviewedAt DateTime?
|
||||
reviewedById String?
|
||||
scanLock ScanLock @relation(fields: [scanLockId], references: [id], onDelete: Cascade)
|
||||
reviewedBy User? @relation("SyncReportReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([scanLockId])
|
||||
@@index([reviewedById])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { LOCK_INCLUDE, activeLocks, acquireLock, snapshotFor, syncScans } = require('../utils/scanLocks');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
const audit = (req, action, lock, metadata = {}) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'ScanLock', targetId: lock.id,
|
||||
metadata: { eventId: lock.eventId, sectionId: lock.sectionId, deviceName: lock.deviceName, ...metadata },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const lockView = (l) => ({
|
||||
id: l.id, eventId: l.eventId, sectionId: l.sectionId, sectionName: l.section?.name || null,
|
||||
deviceId: l.deviceId, deviceName: l.deviceName, user: l.user, lockedAt: l.lockedAt,
|
||||
snapshotAt: l.snapshotAt, lastSyncAt: l.lastSyncAt, releasedAt: l.releasedAt,
|
||||
});
|
||||
|
||||
/** The lock, if the caller is the person holding it on this device. */
|
||||
async function ownLock(req, res) {
|
||||
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
|
||||
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
|
||||
const deviceId = req.body?.deviceId || req.query?.deviceId;
|
||||
if (lock.userId !== req.user.id || lock.deviceId !== deviceId) { res.status(403); throw new Error('This offline lock belongs to another device.'); }
|
||||
return lock;
|
||||
}
|
||||
|
||||
// @desc Active offline locks for an event
|
||||
// @route GET /api/scan-locks?eventId=
|
||||
// @access Private/Staff
|
||||
const listLocks = async (req, res) => {
|
||||
try {
|
||||
if (!req.query.eventId) { res.status(400); throw new Error('eventId is required'); }
|
||||
res.json((await activeLocks(String(req.query.eventId))).map(lockView));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Lock a section (or the whole event) to this device for offline scanning
|
||||
// @route POST /api/scan-locks { eventId, sectionId?, deviceId, deviceName? }
|
||||
// @access Private/Staff
|
||||
const createLock = async (req, res) => {
|
||||
try {
|
||||
const { eventId, sectionId, deviceId, deviceName } = req.body || {};
|
||||
if (!eventId) { res.status(400); throw new Error('eventId is required'); }
|
||||
const lock = await acquireLock({ eventId, sectionId: sectionId || null, deviceId, deviceName, userId: req.user.id });
|
||||
audit(req, 'scan_lock_acquired', lock);
|
||||
res.status(201).json(lockView(lock));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Download the tickets valid for this lock, for offline scanning
|
||||
// @route GET /api/scan-locks/:id/snapshot?deviceId=
|
||||
// @access Private/Staff (lock holder)
|
||||
const getSnapshot = async (req, res) => {
|
||||
try {
|
||||
const lock = await ownLock(req, res);
|
||||
if (lock.releasedAt) { res.status(410); throw new Error('This offline lock has been released.'); }
|
||||
res.json({ lock: lockView(lock), ...(await snapshotFor(lock)) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Upload scans made offline
|
||||
// @route POST /api/scan-locks/:id/sync { deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }
|
||||
// @access Private/Staff (lock holder — also after a release, so nothing made offline is lost)
|
||||
const syncLock = async (req, res) => {
|
||||
try {
|
||||
const lock = await ownLock(req, res);
|
||||
const result = await syncScans(lock, req.body?.scans);
|
||||
if (result.accepted || result.conflicts.length) {
|
||||
audit(req, 'offline_sync', lock, { accepted: result.accepted, conflicts: result.conflicts.length, duplicates: result.duplicates, afterForceRelease: !!lock.forceReleasedById });
|
||||
}
|
||||
res.json({ accepted: result.accepted, duplicates: result.duplicates, conflicts: result.conflicts, reportId: result.report.id, released: !!lock.releasedAt });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc End offline mode (the device should sync first)
|
||||
// @route DELETE /api/scan-locks/:id { deviceId }
|
||||
// @access Private/Staff (lock holder)
|
||||
const releaseLock = async (req, res) => {
|
||||
try {
|
||||
const lock = await ownLock(req, res);
|
||||
if (!lock.releasedAt) {
|
||||
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date() } });
|
||||
audit(req, 'scan_lock_released', lock);
|
||||
}
|
||||
res.json({ message: 'Offline mode ended.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Release someone else's lock (e.g. a lost or flat phone)
|
||||
// @route POST /api/scan-locks/:id/force-release
|
||||
// @access Private/Supervisor
|
||||
const forceRelease = async (req, res) => {
|
||||
try {
|
||||
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
|
||||
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
|
||||
if (lock.releasedAt) return res.json({ message: 'Already released.' });
|
||||
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date(), forceReleasedById: req.user.id } });
|
||||
audit(req, 'scan_lock_force_released', lock, { heldBy: lock.user?.name });
|
||||
res.json({ message: `Released. If ${lock.user?.name || 'that device'} reconnects, its offline scans will still sync and be flagged for review.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Offline sync reports for an event (with anything that needs review)
|
||||
// @route GET /api/scan-locks/reports?eventId=&unreviewed=true
|
||||
// @access Private/Supervisor
|
||||
const listReports = async (req, res) => {
|
||||
try {
|
||||
const where = {
|
||||
scanLock: { eventId: String(req.query.eventId || '') },
|
||||
...(req.query.unreviewed === 'true' ? { reviewedAt: null } : {}),
|
||||
};
|
||||
const reports = await prisma.offlineSyncReport.findMany({
|
||||
where,
|
||||
include: { scanLock: { include: LOCK_INCLUDE }, reviewedBy: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
res.json(reports.map(r => ({ ...r, scanLock: lockView(r.scanLock) })));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Mark a sync report as reviewed
|
||||
// @route POST /api/scan-locks/reports/:id/review
|
||||
// @access Private/Supervisor
|
||||
const reviewReport = async (req, res) => {
|
||||
try {
|
||||
const report = await prisma.offlineSyncReport.update({ where: { id: req.params.id }, data: { reviewedAt: new Date(), reviewedById: req.user.id } });
|
||||
res.json(report);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { SUPERVISORS, listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport };
|
||||
@@ -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');
|
||||
}
|
||||
@@ -372,6 +378,27 @@ const scanTicket = async (req, res) => {
|
||||
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
|
||||
}
|
||||
|
||||
// Section checks used to happen only in the browser; enforce them here too.
|
||||
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
|
||||
if (sectionId) {
|
||||
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
|
||||
if (!section || section.eventId !== ticket.eventId) {
|
||||
return res.status(400).json({ message: 'Section not found for this event', ticket });
|
||||
}
|
||||
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
|
||||
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
|
||||
}
|
||||
}
|
||||
|
||||
// A section (or the whole event) locked to another device for offline scanning
|
||||
// can't be scanned online — those scans would be invisible to the offline device.
|
||||
const { blockingLock } = require('../utils/scanLocks');
|
||||
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
|
||||
if (lock) {
|
||||
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
|
||||
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
|
||||
}
|
||||
|
||||
// Compute how many have already been redeemed
|
||||
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (ticket.quantity || 1) - totalRedeemed;
|
||||
@@ -461,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' },
|
||||
@@ -670,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;
|
||||
@@ -867,5 +964,7 @@ module.exports = {
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
getScanStats,
|
||||
transferTicketHandler,
|
||||
getTicketTransfers
|
||||
};
|
||||
@@ -178,6 +178,7 @@ app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/coupons', require('./routes/couponRoutes'));
|
||||
app.use('/api/consent', require('./routes/consentRoutes'));
|
||||
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
|
||||
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
|
||||
app.use('/api/backups', backupRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
@@ -634,6 +635,26 @@ 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',
|
||||
request:{ body:{ eventId:'ev-uuid-...', sectionId:'sec-uuid-...', deviceId:'device-uuid', deviceName:'Chrome on Android' }},
|
||||
responses:[{ status:201, desc:'Locked', body:{ id:'lock-uuid-...', sectionName:'Main hall' }}, { status:409, desc:'Held elsewhere', body:{ message:'"Main hall" is offline-locked by Ann on Chrome on Android.' }}]},
|
||||
{ method:'GET', path:'/api/scan-locks/:id/snapshot', auth:'staff+', desc:'Tickets valid in the lock scope, for the device to keep offline (lock holder, ?deviceId=)' },
|
||||
{ method:'POST', path:'/api/scan-locks/:id/sync', auth:'staff+', desc:'Upload offline scans (idempotent by clientScanId; capped at what is left on each ticket)',
|
||||
request:{ body:{ deviceId:'device-uuid', scans:[{ clientScanId:'scan-uuid', qrCode:'qr-uuid', qty:1, scannedAt:'2025-07-10T08:15:00.000Z' }] }},
|
||||
responses:[{ status:200, desc:'Synced', body:{ accepted:1, duplicates:0, conflicts:[] }}]},
|
||||
{ method:'DELETE', path:'/api/scan-locks/:id', auth:'staff+', desc:'End offline mode (lock holder)' },
|
||||
{ method:'POST', path:'/api/scan-locks/:id/force-release', auth:'supervisor+', desc:'Release a lost device\'s lock' },
|
||||
{ method:'GET', path:'/api/scan-locks/reports', auth:'supervisor+', desc:'Offline sync reports for an event' },
|
||||
{ method:'POST', path:'/api/scan-locks/reports/:id/review', auth:'supervisor+', desc:'Mark a sync report reviewed' },
|
||||
]},
|
||||
{ title: 'Waitlist', base: '/api/waitlist', endpoints: [
|
||||
{ method:'POST', path:'/api/waitlist', auth:'public', desc:'Join the waitlist for a sold-out line (login needed when the event requires it)',
|
||||
request:{ body:{ eventId:'ev-uuid-...', eventOptionId:'opt-uuid-...', quantity:2 }},
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport } = require('../controllers/scanLockController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', protect, staff, listLocks);
|
||||
router.post('/', protect, staff, createLock);
|
||||
router.get('/reports', protect, supervisor, listReports);
|
||||
router.post('/reports/:id/review', protect, supervisor, reviewReport);
|
||||
router.get('/:id/snapshot', protect, staff, getSnapshot);
|
||||
router.post('/:id/sync', protect, staff, syncLock);
|
||||
router.post('/:id/force-release', protect, supervisor, forceRelease);
|
||||
router.delete('/:id', protect, staff, releaseLock);
|
||||
|
||||
module.exports = router;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Offline scanning — locks, snapshots and syncing.
|
||||
*
|
||||
* Rules:
|
||||
* - Offline mode is per section (or the whole event), and only one device may hold a
|
||||
* scope at a time: while offline, devices can't see each other's scans, so two
|
||||
* devices on the same scope could redeem one ticket twice. A whole-event lock clashes
|
||||
* with every section of that event, and a section lock with the whole event.
|
||||
* - While a scope is locked, other devices can't scan it online either.
|
||||
* - The lock holder downloads a snapshot of valid tickets; scans made offline are
|
||||
* queued on the device and synced later. Syncing is idempotent (each scan carries a
|
||||
* clientScanId) and never redeems more than is left on a ticket.
|
||||
* - Anything that needs a person to look — unknown ticket, cancelled registration, used
|
||||
* up, outside the locked scope, consent not given — goes into an OfflineSyncReport.
|
||||
* - A supervisor can force-release a lock (lost phone). If that device syncs later its
|
||||
* scans are still recorded, and the report is flagged.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
function lockError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Whether a new lock on `sectionId` (null = whole event) clashes with `existing`. */
|
||||
function locksClash(existing, sectionId) {
|
||||
if (!existing.sectionId || !sectionId) return true;
|
||||
return existing.sectionId === sectionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `lock` covers a scan of a ticket with option `optionId`. When the scanner has
|
||||
* picked a section, only a lock on that section (or the whole event) covers it; with no
|
||||
* section picked, any lock on a section that admits the ticket does.
|
||||
* @param {object} lock - { sectionId, section?: { allowedOptions: [{ eventOptionId }] } }
|
||||
*/
|
||||
function lockCovers(lock, { sectionId = null, optionId }) {
|
||||
if (!lock.sectionId) return true;
|
||||
if (sectionId) return sectionId === lock.sectionId;
|
||||
return (lock.section?.allowedOptions || []).some(o => o.eventOptionId === optionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do with one synced scan, given the ticket as the server sees it now. Pure.
|
||||
* @param {object|null} ticket - { remaining, cancelled, inScope, consentOk }
|
||||
* @param {number} qty - quantity the device redeemed
|
||||
* @returns {{ redeem: number, conflict: string|null }}
|
||||
*/
|
||||
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' };
|
||||
const redeem = Math.min(Math.max(1, qty || 1), ticket.remaining);
|
||||
if (!ticket.consentOk) return { redeem, conflict: 'consent' };
|
||||
return { redeem, conflict: redeem < (qty || 1) ? 'partly_used' : null };
|
||||
}
|
||||
|
||||
const CONFLICT_TEXT = {
|
||||
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',
|
||||
partly_used: 'Fewer places were left than were checked in offline',
|
||||
consent: 'Checked in without parental consent',
|
||||
};
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const LOCK_INCLUDE = {
|
||||
section: { include: { allowedOptions: true } },
|
||||
user: { select: { id: true, name: true } },
|
||||
};
|
||||
|
||||
async function activeLocks(eventId, db = prisma) {
|
||||
return db.scanLock.findMany({ where: { eventId, releasedAt: null }, include: LOCK_INCLUDE, orderBy: { lockedAt: 'asc' } });
|
||||
}
|
||||
|
||||
/** Locks a scope to this device. Re-locking by the same device and person returns the existing lock. */
|
||||
async function acquireLock({ eventId, sectionId = null, deviceId, deviceName, userId }) {
|
||||
if (!deviceId || typeof deviceId !== 'string' || deviceId.length > 100) throw lockError('A device id is required');
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'scanlock:' + eventId}))`;
|
||||
const event = await tx.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) throw lockError('Event not found', 404);
|
||||
if (sectionId) {
|
||||
const section = await tx.section.findUnique({ where: { id: sectionId } });
|
||||
if (!section || section.eventId !== eventId) throw lockError('Section not found for this event', 404);
|
||||
}
|
||||
const existing = await activeLocks(eventId, tx);
|
||||
const same = existing.find(l => (l.sectionId || null) === (sectionId || null) && l.deviceId === deviceId && l.userId === userId);
|
||||
if (same) return same;
|
||||
const clash = existing.find(l => locksClash(l, sectionId));
|
||||
if (clash) {
|
||||
const scope = clash.section ? `"${clash.section.name}"` : 'the whole event';
|
||||
throw lockError(`${scope} is offline-locked by ${clash.user.name}${clash.deviceName ? ` on ${clash.deviceName}` : ''}.`, 409);
|
||||
}
|
||||
return tx.scanLock.create({ data: { eventId, sectionId: sectionId || null, deviceId, deviceName: deviceName ? String(deviceName).slice(0, 100) : null, userId }, include: LOCK_INCLUDE });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The lock (other than the scanner's own device) that stops this online scan, or null.
|
||||
* @param {object} ticket - { eventId, optionId }
|
||||
*/
|
||||
async function blockingLock(ticket, { sectionId = null, deviceId = null } = {}) {
|
||||
const locks = await activeLocks(ticket.eventId);
|
||||
return locks.find(l => l.deviceId !== deviceId && lockCovers(l, { sectionId, optionId: ticket.optionId })) || null;
|
||||
}
|
||||
|
||||
/** Tickets valid in a lock's scope, for the device to keep offline. */
|
||||
async function snapshotFor(lock) {
|
||||
const optionIds = lock.section ? lock.section.allowedOptions.map(o => o.eventOptionId) : null;
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: {
|
||||
eventId: lock.eventId,
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } }, ...(optionIds ? { eventOptionId: { in: optionIds } } : {}) },
|
||||
},
|
||||
include: {
|
||||
usages: { select: { quantityRedeemed: true } },
|
||||
attendee: { select: { name: true } },
|
||||
user: { select: { name: true } },
|
||||
registrationOption: { include: { eventOption: { select: { id: true, name: true, isMainTicket: true } }, variant: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
const { consentStatusForTickets } = require('./consent');
|
||||
const consent = await consentStatusForTickets(tickets.map(t => t.id));
|
||||
const snapshotAt = new Date();
|
||||
await prisma.scanLock.update({ where: { id: lock.id }, data: { snapshotAt } });
|
||||
return {
|
||||
snapshotAt,
|
||||
tickets: tickets.map(t => {
|
||||
const redeemed = t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
return {
|
||||
id: t.id,
|
||||
qrCode: t.qrCode,
|
||||
holder: t.attendee?.name || t.user?.name || '',
|
||||
bookedBy: t.attendee ? t.user?.name || '' : '',
|
||||
option: t.registrationOption.eventOption.name,
|
||||
variant: t.registrationOption.variant?.name || null,
|
||||
quantity: t.quantity || 1,
|
||||
remaining: Math.max(0, (t.quantity || 1) - redeemed),
|
||||
consentStatus: consent.get(t.id) || 'not_required',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records queued offline scans. Each scan: { clientScanId, qrCode, qty, scannedAt }.
|
||||
* @returns {Promise<{ accepted, duplicates, conflicts, report }>}
|
||||
*/
|
||||
async function syncScans(lock, scans) {
|
||||
const results = { accepted: 0, duplicates: 0, conflicts: [] };
|
||||
const optionIds = lock.section ? new Set(lock.section.allowedOptions.map(o => o.eventOptionId)) : null;
|
||||
const { consentStatusForTickets } = require('./consent');
|
||||
const checkIns = [];
|
||||
|
||||
for (const scan of (scans || []).slice(0, 2000)) {
|
||||
const clientScanId = String(scan?.clientScanId || '').slice(0, 100);
|
||||
if (!clientScanId) continue;
|
||||
if (await prisma.ticketUsage.findUnique({ where: { clientScanId } })) { results.duplicates += 1; continue; }
|
||||
|
||||
const qty = Math.max(1, parseInt(scan.qty, 10) || 1);
|
||||
const scannedAt = scan.scannedAt && !isNaN(new Date(scan.scannedAt)) ? new Date(scan.scannedAt) : new Date();
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { qrCode: String(scan.qrCode || '') },
|
||||
include: { usages: true, registrationOption: { include: { registration: { select: { status: true } }, eventOption: { select: { isMainTicket: true } } } } },
|
||||
});
|
||||
let state = null;
|
||||
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 = {
|
||||
remaining: (ticket.quantity || 1) - redeemed,
|
||||
cancelled: ticket.registrationOption.registration.status === 'cancelled',
|
||||
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
|
||||
consentOk: ['not_required', 'given'].includes(consent),
|
||||
};
|
||||
}
|
||||
const verdict = judgeSyncedScan(state, qty);
|
||||
if (verdict.redeem > 0) {
|
||||
await prisma.ticketUsage.create({
|
||||
data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date() },
|
||||
});
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: state.remaining - verdict.redeem <= 0, updatedAt: new Date() } });
|
||||
results.accepted += 1;
|
||||
if (ticket.registrationOption.eventOption.isMainTicket) {
|
||||
checkIns.push({ ticketId: ticket.id, qty: verdict.redeem, total: (ticket.quantity || 1) - state.remaining + verdict.redeem, left: state.remaining - verdict.redeem });
|
||||
}
|
||||
}
|
||||
if (verdict.conflict) {
|
||||
results.conflicts.push({ clientScanId, qrCode: scan.qrCode, ticketId: ticket?.id || null, qty, redeemed: verdict.redeem, scannedAt, reason: verdict.conflict, message: CONFLICT_TEXT[verdict.conflict] });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.scanLock.update({ where: { id: lock.id }, data: { lastSyncAt: new Date() } });
|
||||
const report = await prisma.offlineSyncReport.create({
|
||||
data: { scanLockId: lock.id, accepted: results.accepted, duplicates: results.duplicates, conflicts: results.conflicts, afterForceRelease: !!lock.forceReleasedById },
|
||||
});
|
||||
|
||||
// Check-in confirmations go out after sync, not at the moment of the offline scan.
|
||||
const { sendCheckInEmails } = require('./notifications');
|
||||
for (const c of checkIns) sendCheckInEmails(c.ticketId, c.qty, c.total, c.left).catch(() => {});
|
||||
|
||||
return { ...results, report };
|
||||
}
|
||||
|
||||
module.exports = { lockError, locksClash, lockCovers, judgeSyncedScan, CONFLICT_TEXT, LOCK_INCLUDE, activeLocks, acquireLock, blockingLock, snapshotFor, syncScans };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,54 @@
|
||||
const { locksClash, lockCovers, judgeSyncedScan } = require('../src/utils/scanLocks');
|
||||
|
||||
describe('locksClash', () => {
|
||||
test('a whole-event lock clashes with everything', () => {
|
||||
expect(locksClash({ sectionId: null }, 'hall')).toBe(true);
|
||||
expect(locksClash({ sectionId: 'hall' }, null)).toBe(true);
|
||||
});
|
||||
|
||||
test('section locks only clash with the same section', () => {
|
||||
expect(locksClash({ sectionId: 'hall' }, 'hall')).toBe(true);
|
||||
expect(locksClash({ sectionId: 'hall' }, 'dinner')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lockCovers', () => {
|
||||
const dinnerLock = { sectionId: 'dinner', section: { allowedOptions: [{ eventOptionId: 'meal' }] } };
|
||||
|
||||
test('a whole-event lock covers every scan', () => {
|
||||
expect(lockCovers({ sectionId: null }, { sectionId: 'x', optionId: 'y' })).toBe(true);
|
||||
});
|
||||
|
||||
test('with a section picked, only that section is covered', () => {
|
||||
expect(lockCovers(dinnerLock, { sectionId: 'dinner', optionId: 'meal' })).toBe(true);
|
||||
expect(lockCovers(dinnerLock, { sectionId: 'hall', optionId: 'meal' })).toBe(false);
|
||||
});
|
||||
|
||||
test('with no section picked, tickets the locked section admits are covered', () => {
|
||||
expect(lockCovers(dinnerLock, { optionId: 'meal' })).toBe(true);
|
||||
expect(lockCovers(dinnerLock, { optionId: 'entry' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('judgeSyncedScan', () => {
|
||||
const ok = { remaining: 2, cancelled: false, inScope: true, consentOk: true };
|
||||
|
||||
test('records a normal scan', () => {
|
||||
expect(judgeSyncedScan(ok, 1)).toEqual({ redeem: 1, conflict: null });
|
||||
});
|
||||
|
||||
test('never redeems more than is left', () => {
|
||||
expect(judgeSyncedScan(ok, 3)).toEqual({ redeem: 2, conflict: 'partly_used' });
|
||||
expect(judgeSyncedScan({ ...ok, remaining: 0 }, 1)).toEqual({ redeem: 0, conflict: 'already_used' });
|
||||
});
|
||||
|
||||
test('flags unknown, cancelled and out-of-scope tickets without recording them', () => {
|
||||
expect(judgeSyncedScan(null, 1)).toEqual({ redeem: 0, conflict: 'not_found' });
|
||||
expect(judgeSyncedScan({ ...ok, cancelled: true }, 1)).toEqual({ redeem: 0, conflict: 'cancelled' });
|
||||
expect(judgeSyncedScan({ ...ok, inScope: false }, 1)).toEqual({ redeem: 0, conflict: 'wrong_scope' });
|
||||
});
|
||||
|
||||
test('records but flags a check-in without consent', () => {
|
||||
expect(judgeSyncedScan({ ...ok, consentOk: false }, 1)).toEqual({ redeem: 1, conflict: 'consent' });
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Generated
+9
-62
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.18.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.18.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
@@ -31,6 +31,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"idb": "^8.0.4",
|
||||
"lucide-react": "^0.536.0",
|
||||
"next": "^15.5.24",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -869,9 +870,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -888,9 +886,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -907,9 +902,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -926,9 +918,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -945,9 +934,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -964,9 +950,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -983,9 +966,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1002,9 +982,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1021,9 +998,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1046,9 +1020,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1071,9 +1042,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1096,9 +1064,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1121,9 +1086,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1146,9 +1108,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1171,9 +1130,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1196,9 +1152,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1455,9 +1408,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1474,9 +1424,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1493,9 +1440,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1512,9 +1456,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6865,6 +6806,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.4.tgz",
|
||||
"integrity": "sha512-jd0ITCrN+Zah4J7R/Z2jnR3W7A4BXDAjmo62MwWuSjxCiueUAf7X5rbodEdidWzeknwXHmLpdZkoiiCppNi30Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.18.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -32,6 +32,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"idb": "^8.0.4",
|
||||
"lucide-react": "^0.536.0",
|
||||
"next": "^15.5.24",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Service worker for offline ticket scanning.
|
||||
*
|
||||
* Only the scanner page and the static files it needs are cached:
|
||||
* - /_next/static/* (hashed, never change): cache-first.
|
||||
* - The scanner page itself: network-first, falling back to the cached copy offline.
|
||||
* - The page can ask (postMessage { type: 'cache-urls', urls }) for the files it has
|
||||
* loaded to be cached up front, so it works offline straight after "Enable offline mode".
|
||||
* API calls are never cached — offline scans go through IndexedDB instead.
|
||||
*/
|
||||
const CACHE = 'hope-scanner-v1';
|
||||
const SCANNER_PATH = '/dashboard/staff/ticket-scanning';
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter(k => k.startsWith('hope-scanner-') && k !== CACHE).map(k => caches.delete(k)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
const data = event.data || {};
|
||||
if (data.type !== 'cache-urls' || !Array.isArray(data.urls)) return;
|
||||
event.waitUntil((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const sameOrigin = data.urls.filter(u => {
|
||||
try { return new URL(u, self.location.origin).origin === self.location.origin; } catch { return false; }
|
||||
});
|
||||
await Promise.all(sameOrigin.map(u => cache.add(u).catch(() => {})));
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (url.pathname.startsWith('/_next/static/')) {
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const hit = await cache.match(req);
|
||||
if (hit) return hit;
|
||||
const res = await fetch(req);
|
||||
if (res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
})());
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === SCANNER_PATH) {
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
try {
|
||||
const res = await fetch(req);
|
||||
if (res.ok) cache.put(SCANNER_PATH, res.clone());
|
||||
return res;
|
||||
} catch {
|
||||
const hit = (await cache.match(req, { ignoreSearch: true })) || (await cache.match(SCANNER_PATH));
|
||||
if (hit) return hit;
|
||||
throw new Error('offline and not cached');
|
||||
}
|
||||
})());
|
||||
}
|
||||
});
|
||||
@@ -25,6 +25,11 @@ const ACTIONS = [
|
||||
"consent_override",
|
||||
"consent_rerequested",
|
||||
"medical_list_viewed",
|
||||
"scan_lock_acquired",
|
||||
"scan_lock_released",
|
||||
"scan_lock_force_released",
|
||||
"offline_sync",
|
||||
"ticket_transferred",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
@@ -44,6 +49,11 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
consent_override: "Consent overridden at check-in",
|
||||
consent_rerequested: "Consent re-requested",
|
||||
medical_list_viewed: "Medical list viewed",
|
||||
scan_lock_acquired: "Offline scanning started",
|
||||
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 {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -27,6 +27,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
|
||||
|
||||
@@ -7,6 +7,9 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { QrCode } from "lucide-react";
|
||||
import { useOfflineScanning } from "@/components/scanner/useOfflineScanning";
|
||||
import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
|
||||
import type { SnapshotTicket } from "@/lib/offlineScan";
|
||||
|
||||
export default function TicketScanningPage() {
|
||||
const router = useRouter();
|
||||
@@ -17,6 +20,7 @@ export default function TicketScanningPage() {
|
||||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
|
||||
const [errorModal, setErrorModal] = useState<string | null>(null);
|
||||
const { token, user } = useAuth();
|
||||
const offline = useOfflineScanning(token);
|
||||
const [recentScans, setRecentScans] = useState<any[]>([]);
|
||||
const [stats, setStats] = useState<any | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState<boolean>(false);
|
||||
@@ -34,6 +38,7 @@ export default function TicketScanningPage() {
|
||||
qrCode: string;
|
||||
ticket: any;
|
||||
remaining: number;
|
||||
offlineTicket?: SnapshotTicket; // scanned against the downloaded list (offline mode)
|
||||
} | null>(null);
|
||||
const [confirmQty, setConfirmQty] = useState(1);
|
||||
// Youth events: the preview says whether parental consent allows check-in.
|
||||
@@ -96,6 +101,16 @@ export default function TicketScanningPage() {
|
||||
})();
|
||||
}, [selectedEventId, token]);
|
||||
|
||||
// In offline mode the event and section are fixed to the locked scope.
|
||||
useEffect(() => {
|
||||
if (!offline.lock) return;
|
||||
setSelectedEventId(offline.lock.eventId);
|
||||
}, [offline.lock]);
|
||||
useEffect(() => {
|
||||
if (!offline.lock) return;
|
||||
setSelectedSectionId(offline.lock.sectionId || "all");
|
||||
}, [offline.lock, sections]);
|
||||
|
||||
// Bug fix: stop the scanner when the event or section filter changes so stale scan
|
||||
// context is never used. The user must press "Scan Ticket" again to resume.
|
||||
const isFirstFilterRender = useRef(true);
|
||||
@@ -114,6 +129,32 @@ export default function TicketScanningPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Offline mode: check the ticket against the list downloaded to this device.
|
||||
if (offline.lock) {
|
||||
const t = await offline.findTicket(text);
|
||||
if (!t) { setErrorModal("Not found in the offline ticket list. Check at the door desk."); return; }
|
||||
if (t.remaining <= 0) { setAlreadyUsedModal({ message: "Ticket has already been fully used", ticket: { id: t.id } }); return; }
|
||||
if (!["not_required", "given"].includes(t.consentStatus)) {
|
||||
setErrorModal(t.consentStatus === "declined" ? "Parental consent was declined — this ticket can't be checked in." : "Parental consent is missing or pending — this ticket can't be checked in offline. Check at the door desk.");
|
||||
return;
|
||||
}
|
||||
setConfirmQty(t.remaining);
|
||||
setConfirmQtyRaw(String(t.remaining));
|
||||
setConfirmModal({
|
||||
qrCode: text,
|
||||
remaining: t.remaining,
|
||||
offlineTicket: t,
|
||||
ticket: {
|
||||
id: t.id, quantity: t.quantity, consentStatus: t.consentStatus,
|
||||
event: { title: offline.lock.eventTitle },
|
||||
registrationOption: { eventOption: { name: t.option }, variant: t.variant ? { name: t.variant } : null },
|
||||
attendee: t.bookedBy ? { name: t.holder } : null,
|
||||
user: { name: t.bookedBy || t.holder },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Always preview first to validate the ticket before showing confirm step
|
||||
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
|
||||
let preview: any;
|
||||
@@ -172,7 +213,7 @@ export default function TicketScanningPage() {
|
||||
if (!confirmModal || !token) return;
|
||||
|
||||
// Capture everything before clearing modal state
|
||||
const { qrCode, ticket, remaining } = confirmModal;
|
||||
const { qrCode, ticket, remaining, offlineTicket } = confirmModal;
|
||||
const qty = confirmQty;
|
||||
const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket";
|
||||
const variantName = ticket?.registrationOption?.variant?.name;
|
||||
@@ -194,6 +235,17 @@ export default function TicketScanningPage() {
|
||||
total: ticket?.quantity || 1,
|
||||
});
|
||||
|
||||
// Offline mode: saved on this device, synced when there's a connection.
|
||||
if (offlineTicket) {
|
||||
try {
|
||||
await offline.scanLocally(offlineTicket, qty);
|
||||
} catch (e: any) {
|
||||
setSuccessModal(null);
|
||||
setErrorModal(e?.message || "Couldn't save the scan on this device");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Background write — reverse the optimistic update on failure
|
||||
try {
|
||||
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
|
||||
@@ -202,7 +254,12 @@ export default function TicketScanningPage() {
|
||||
await apiFetch<any>(url, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { qty, ...(overrideConsent ? { overrideConsent: true } : {}) },
|
||||
body: {
|
||||
qty,
|
||||
deviceId: offline.deviceId(),
|
||||
...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}),
|
||||
...(overrideConsent ? { overrideConsent: true } : {}),
|
||||
},
|
||||
});
|
||||
refreshStatsAndRecent();
|
||||
} catch (e: any) {
|
||||
@@ -270,8 +327,10 @@ export default function TicketScanningPage() {
|
||||
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
|
||||
value={selectedEventId}
|
||||
onChange={(e) => setSelectedEventId(e.target.value)}
|
||||
disabled={!!offline.lock}
|
||||
>
|
||||
<option value="all">All events</option>
|
||||
{offline.lock && !events.some(ev => ev.id === offline.lock!.eventId) && <option value={offline.lock.eventId}>{offline.lock.eventTitle}</option>}
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
@@ -289,6 +348,7 @@ export default function TicketScanningPage() {
|
||||
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
|
||||
value={selectedSectionId}
|
||||
onChange={(e) => setSelectedSectionId(e.target.value)}
|
||||
disabled={!!offline.lock}
|
||||
>
|
||||
<option value="all">All sections</option>
|
||||
{sections.map(section => (
|
||||
@@ -299,6 +359,16 @@ export default function TicketScanningPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OfflineScanPanel
|
||||
offline={offline}
|
||||
token={token}
|
||||
eventId={selectedEventId}
|
||||
eventTitle={events.find(ev => ev.id === selectedEventId)?.title || offline.lock?.eventTitle || ""}
|
||||
sectionId={selectedSectionId !== "all" ? selectedSectionId : null}
|
||||
sectionName={sections.find(s => s.id === selectedSectionId)?.name || null}
|
||||
onError={setError}
|
||||
onInfo={setScanInfo}
|
||||
/>
|
||||
<QRScanner
|
||||
ref={scannerRef}
|
||||
onResult={handleResult}
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendTarget, setSendTarget] = useState<any | null>(null);
|
||||
const [transferTarget, setTransferTarget] = useState<any | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
// Load all tickets for the event once
|
||||
useEffect(() => {
|
||||
@@ -1000,6 +1003,7 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && <div className="mt-2 p-2 rounded bg-green-50 text-green-800 text-sm">{notice}</div>}
|
||||
<div className="mt-3 space-y-2 max-h-96 overflow-auto">
|
||||
{tickets.map(t => (
|
||||
<div
|
||||
@@ -1008,8 +1012,11 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{t.user?.name || "Guest"}
|
||||
{t.attendee?.name || t.user?.name || "Guest"}
|
||||
</div>
|
||||
{t.attendee?.name && t.user?.name && t.attendee.name !== t.user.name && (
|
||||
<div className="text-xs text-gray-500">Booked by {t.user.name}</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500">
|
||||
{ticketLabel(t)}
|
||||
</div>
|
||||
@@ -1028,6 +1035,14 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
{canOfferTransfer(t, t.event, true) && (
|
||||
<button
|
||||
onClick={() => setTransferTarget(t)}
|
||||
className="px-3 py-1 text-xs rounded border hover:bg-gray-50"
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1039,6 +1054,15 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{transferTarget && (
|
||||
<TransferTicketModal
|
||||
ticket={transferTarget}
|
||||
event={transferTarget.event}
|
||||
token={token}
|
||||
onClose={() => setTransferTarget(null)}
|
||||
onDone={(msg) => { setTransferTarget(null); setNotice(msg); load(); }}
|
||||
/>
|
||||
)}
|
||||
{sendTarget && (
|
||||
<SendTicketsModal
|
||||
open={true}
|
||||
|
||||
@@ -773,6 +773,7 @@ interface EventDraft {
|
||||
requiresParentalConsent: boolean; consentText: string;
|
||||
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
|
||||
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
|
||||
allowTicketTransfer: boolean;
|
||||
}
|
||||
|
||||
const blankDraft = (location = ""): EventDraft => ({
|
||||
@@ -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) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Ticket transfer</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.allowTicketTransfer} onChange={e => upd({ allowTicketTransfer: e.target.checked })} />
|
||||
<span><strong>Let people transfer their tickets</strong> — before the event, a ticket that hasn't been scanned can be handed to someone else, who gets a new QR code (supervisors can always transfer)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Youth event</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
|
||||
|
||||
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
|
||||
type Report = {
|
||||
id: string; accepted: number; duplicates: number; afterForceRelease: boolean; createdAt: string; reviewedAt: string | null;
|
||||
reviewedBy: { name: string } | null;
|
||||
conflicts: { clientScanId: string; qrCode: string; qty: number; redeemed: number; scannedAt: string; reason: string; message: string }[];
|
||||
scanLock: Lock;
|
||||
};
|
||||
|
||||
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : "—");
|
||||
|
||||
export default function OfflineScansPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [locks, setLocks] = useState<Lock[]>([]);
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [onlyOpen, setOnlyOpen] = useState(true);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const recent = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 14 * 864e5);
|
||||
setEvents(recent);
|
||||
if (recent.length && !eventId) setEventId(recent[0].id);
|
||||
})
|
||||
.catch(e => setError(e?.message || "Failed to load events"));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, canView]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
const [l, r] = await Promise.all([
|
||||
apiFetch<Lock[]>(`/api/scan-locks?eventId=${encodeURIComponent(eventId)}`, { authToken: token }),
|
||||
apiFetch<Report[]>(`/api/scan-locks/reports?eventId=${encodeURIComponent(eventId)}${onlyOpen ? "&unreviewed=true" : ""}`, { authToken: token }),
|
||||
]);
|
||||
setLocks(l);
|
||||
setReports(r.filter(x => !onlyOpen || x.conflicts.length > 0 || x.afterForceRelease));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load offline scanning");
|
||||
}
|
||||
}, [token, eventId, onlyOpen, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const forceRelease = async (l: Lock) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/scan-locks/${l.id}/force-release`, { method: "POST", authToken: token! });
|
||||
setInfo(res.message);
|
||||
load();
|
||||
} catch (e: any) { setError(e?.message || "Failed"); }
|
||||
};
|
||||
const review = async (r: Report) => {
|
||||
try {
|
||||
await apiFetch(`/api/scan-locks/reports/${r.id}/review`, { method: "POST", authToken: token! });
|
||||
load();
|
||||
} catch (e: any) { setError(e?.message || "Failed"); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<WifiOff className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Offline scanning</h1>
|
||||
<p className="text-sm text-gray-500">Devices scanning without a connection, and their synced scans</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor access to manage offline scanning.</div>}
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
|
||||
|
||||
{canView && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 pb-2">
|
||||
<input type="checkbox" checked={onlyOpen} onChange={e => setOnlyOpen(e.target.checked)} /> Only reports that need review
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="font-semibold mb-2">Locked sections</h2>
|
||||
{locks.length === 0 ? <p className="text-sm text-gray-500">No device is scanning offline for this event.</p> : (
|
||||
<ul className="divide-y">
|
||||
{locks.map(l => (
|
||||
<li key={l.id} className="py-2 flex items-center justify-between gap-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">{l.sectionName || "Whole event"} — {l.user.name}{l.deviceName ? ` on ${l.deviceName}` : ""}</div>
|
||||
<div className="text-xs text-gray-500">Locked {when(l.lockedAt)} · list downloaded {when(l.snapshotAt)} · last sync {when(l.lastSyncAt)}</div>
|
||||
</div>
|
||||
<button onClick={() => forceRelease(l)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border hover:bg-gray-50 shrink-0" title="For a lost or flat device. If it reconnects later, its scans still sync and are flagged here.">
|
||||
<Unlock className="w-3.5 h-3.5" /> Force release
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="font-semibold mb-2">Sync reports</h2>
|
||||
{reports.length === 0 ? <p className="text-sm text-gray-500">{onlyOpen ? "Nothing needs review." : "No offline scans have been synced for this event."}</p> : (
|
||||
<div className="space-y-3">
|
||||
{reports.map(r => (
|
||||
<div key={r.id} className={`border rounded-lg p-3 ${r.reviewedAt ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{when(r.createdAt)}</span> — {r.scanLock.user?.name}{r.scanLock.deviceName ? ` on ${r.scanLock.deviceName}` : ""}{r.scanLock.sectionName ? `, ${r.scanLock.sectionName}` : ""}
|
||||
<div className="text-xs text-gray-500">{r.accepted} scan(s) recorded{r.duplicates ? ` · ${r.duplicates} already synced` : ""}{r.afterForceRelease ? " · synced after the lock was force-released" : ""}</div>
|
||||
</div>
|
||||
{r.reviewedAt
|
||||
? <span className="text-xs text-gray-500 inline-flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> Reviewed by {r.reviewedBy?.name}</span>
|
||||
: <button onClick={() => review(r)} className="px-2.5 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 shrink-0">Mark reviewed</button>}
|
||||
</div>
|
||||
{r.conflicts.length > 0 && (
|
||||
<ul className="mt-2 text-xs space-y-1">
|
||||
{r.conflicts.map(c => (
|
||||
<li key={c.clientScanId} className="text-red-800">
|
||||
{new Date(c.scannedAt).toLocaleTimeString()} · ticket {String(c.qrCode).slice(0, 8)}… · {c.message}{c.redeemed ? ` (recorded ${c.redeemed} of ${c.qty})` : " (not recorded)"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -24,6 +24,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
|
||||
import { MyWaitlists } from "@/components/shared/MyWaitlists";
|
||||
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
|
||||
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
|
||||
|
||||
// Helper formatters
|
||||
@@ -534,6 +535,8 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const [transferTarget, setTransferTarget] = useState<any | null>(null);
|
||||
|
||||
// Re-fetch registrations and tickets after something on one changes (attendee names).
|
||||
const reloadAfterAttendees = async () => {
|
||||
if (!token) return;
|
||||
@@ -1003,6 +1006,9 @@ export default function UserDashboardPage() {
|
||||
<button onClick={() => whatsappTickets([t.id])} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1">WhatsApp</button>
|
||||
)}
|
||||
<button onClick={() => printTickets([t])} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Print</button>
|
||||
{canOfferTransfer(t, t.event) && (
|
||||
<button onClick={() => setTransferTarget(t)} className="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-100 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" title="Give this ticket to someone else">Transfer</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -1012,6 +1018,16 @@ export default function UserDashboardPage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{transferTarget && token && (
|
||||
<TransferTicketModal
|
||||
ticket={transferTarget}
|
||||
event={transferTarget.event}
|
||||
token={token}
|
||||
onClose={() => setTransferTarget(null)}
|
||||
onDone={(msg) => { setTransferTarget(null); setInfo(msg); reloadAfterAttendees(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeReg && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-xl w-full mx-4 p-5 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
// Lets staff "Add to home screen" and open the ticket scanner like an app (it also
|
||||
// works offline once offline mode has been enabled — see public/sw.js).
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Ticket scanner",
|
||||
short_name: "Scanner",
|
||||
description: "Scan event tickets, online or offline",
|
||||
start_url: "/dashboard/staff/ticket-scanning",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: "#4F46E5",
|
||||
icons: [{ src: "/favicon.ico", sizes: "any", type: "image/x-icon" }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Wifi, WifiOff, CloudUpload, Lock } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { OtherLock } from "./useOfflineScanning";
|
||||
|
||||
type Offline = ReturnType<typeof import("./useOfflineScanning").useOfflineScanning>;
|
||||
|
||||
/**
|
||||
* Offline-mode controls above the scanner: shows who holds which section, lets this
|
||||
* device lock the selected section and download its tickets, and shows the sync queue.
|
||||
*/
|
||||
export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionId, sectionName, onError, onInfo }: {
|
||||
offline: Offline;
|
||||
token: string | null;
|
||||
eventId: string;
|
||||
eventTitle: string;
|
||||
sectionId: string | null;
|
||||
sectionName: string | null;
|
||||
onError: (m: string) => void;
|
||||
onInfo: (m: string) => void;
|
||||
}) {
|
||||
const [locks, setLocks] = useState<OtherLock[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { lock, pending, online, syncing } = offline;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !eventId || eventId === "all" || !online) { setLocks([]); return; }
|
||||
apiFetch<OtherLock[]>(`/api/scan-locks?eventId=${encodeURIComponent(eventId)}`, { authToken: token })
|
||||
.then(setLocks).catch(() => setLocks([]));
|
||||
}, [token, eventId, online, lock]);
|
||||
|
||||
const others = locks.filter(l => l.deviceId !== offline.deviceId());
|
||||
|
||||
const enable = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const n = await offline.enable(eventId, eventTitle, sectionId, sectionName);
|
||||
onInfo(`Offline mode on — ${n} ticket(s) downloaded. You can keep scanning without a connection.`);
|
||||
} catch (e: any) {
|
||||
onError(e?.message || "Couldn't turn on offline mode");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const release = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await offline.release();
|
||||
onInfo("Offline mode ended and the section is unlocked.");
|
||||
} catch (e: any) {
|
||||
onError(e?.message || "Couldn't end offline mode");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const syncNow = async () => {
|
||||
try {
|
||||
const r = await offline.sync();
|
||||
if (r) onInfo(`Synced ${r.accepted} scan(s)${r.conflicts ? ` — ${r.conflicts} need a supervisor to review` : ""}.`);
|
||||
} catch (e: any) {
|
||||
onError(e?.message || "Sync failed — scans are kept on this device and will retry.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 mb-4 text-sm ${lock ? "bg-amber-50 border-amber-200" : "bg-white"}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{online ? <Wifi className="w-4 h-4 text-green-600" /> : <WifiOff className="w-4 h-4 text-red-600" />}
|
||||
<span className={online ? "text-green-700" : "text-red-700"}>{online ? "Online" : "Offline"}</span>
|
||||
{lock ? (
|
||||
<>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span className="font-medium text-amber-900">Offline mode: {lock.eventTitle}{lock.sectionName ? ` — ${lock.sectionName}` : " — whole event"}</span>
|
||||
<span className="text-xs text-gray-600">Ticket list downloaded at {new Date(lock.snapshotAt).toLocaleTimeString()}</span>
|
||||
<span className="text-xs text-gray-600">· {pending} scan(s) waiting to sync</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button onClick={syncNow} disabled={!online || syncing || pending === 0} className="inline-flex items-center gap-1 px-2.5 py-1 rounded border bg-white text-xs disabled:opacity-50"><CloudUpload className="w-3.5 h-3.5" /> {syncing ? "Syncing…" : "Sync now"}</button>
|
||||
<button onClick={release} disabled={!online || busy} className="px-2.5 py-1 rounded bg-amber-600 text-white text-xs disabled:opacity-50">Release section</button>
|
||||
</div>
|
||||
</>
|
||||
) : eventId && eventId !== "all" ? (
|
||||
<div className="ml-auto">
|
||||
<button onClick={enable} disabled={!online || busy || others.some(l => !l.sectionId || !sectionId || l.sectionId === sectionId)} className="px-2.5 py-1 rounded border border-brand-300 text-brand-700 text-xs hover:bg-brand-50 disabled:opacity-50" title="Lock this section to this device and download its tickets, so you can keep scanning if the connection drops">
|
||||
{busy ? "Preparing…" : `Enable offline mode${sectionName ? ` for ${sectionName}` : " (whole event)"}`}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500 ml-1">Pick an event to use offline mode.</span>
|
||||
)}
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{others.map(l => (
|
||||
<li key={l.id} className="flex items-center gap-1.5 text-xs text-gray-700">
|
||||
<Lock className="w-3.5 h-3.5 text-amber-600" />
|
||||
{l.sectionName ? `"${l.sectionName}"` : "The whole event"} is offline-locked by {l.user.name}{l.deviceName ? ` on ${l.deviceName}` : ""} — scan it on that device.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{lock && !online && <p className="mt-2 text-xs text-amber-900">Scans are saved on this device and will sync when the connection is back. Tickets bought after the download time won't be on the list.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import {
|
||||
OfflineLock, SnapshotTicket, clearOffline, deviceId, deviceName, findTicket, getOfflineLock,
|
||||
queuedScans, recordScan, removeQueued, startOffline,
|
||||
} from "@/lib/offlineScan";
|
||||
|
||||
export type OtherLock = { id: string; sectionId: string | null; sectionName: string | null; deviceId: string; deviceName: string | null; user: { name: string } };
|
||||
|
||||
/**
|
||||
* Offline mode for the ticket scanner: lock a section to this device, download its
|
||||
* tickets, scan against them with no connection, and sync the queued scans whenever
|
||||
* the device is back online.
|
||||
*/
|
||||
export function useOfflineScanning(token: string | null) {
|
||||
const [lock, setLock] = useState<OfflineLock | null>(null);
|
||||
const [pending, setPending] = useState(0);
|
||||
const [online, setOnline] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [lastSync, setLastSync] = useState<{ accepted: number; conflicts: number; at: string } | null>(null);
|
||||
const syncingRef = useRef(false);
|
||||
|
||||
const refreshPending = useCallback(async () => {
|
||||
try { setPending((await queuedScans()).length); } catch { setPending(0); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLock(getOfflineLock());
|
||||
refreshPending();
|
||||
setOnline(typeof navigator === "undefined" ? true : navigator.onLine);
|
||||
const up = () => setOnline(true);
|
||||
const down = () => setOnline(false);
|
||||
window.addEventListener("online", up);
|
||||
window.addEventListener("offline", down);
|
||||
// Cache the scanner page and everything it has loaded, so it opens offline.
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/sw.js").then(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const urls = [window.location.pathname, ...performance.getEntriesByType("resource").map(e => e.name)];
|
||||
reg.active?.postMessage({ type: "cache-urls", urls });
|
||||
}).catch(() => {});
|
||||
}
|
||||
return () => { window.removeEventListener("online", up); window.removeEventListener("offline", down); };
|
||||
}, [refreshPending]);
|
||||
|
||||
/** Uploads queued scans. Returns the server's verdict, or null when nothing was sent. */
|
||||
const sync = useCallback(async () => {
|
||||
if (!token || syncingRef.current) return null;
|
||||
const queue = await queuedScans();
|
||||
if (queue.length === 0) return null;
|
||||
syncingRef.current = true;
|
||||
setSyncing(true);
|
||||
try {
|
||||
const byLock = new Map<string, typeof queue>();
|
||||
queue.forEach(q => byLock.set(q.lockId, [...(byLock.get(q.lockId) || []), q]));
|
||||
let accepted = 0, conflicts = 0;
|
||||
for (const [lockId, scans] of byLock) {
|
||||
const res = await apiFetch<{ accepted: number; duplicates: number; conflicts: any[] }>(`/api/scan-locks/${lockId}/sync`, {
|
||||
method: "POST", authToken: token, body: { deviceId: deviceId(), scans },
|
||||
});
|
||||
accepted += res.accepted;
|
||||
conflicts += res.conflicts.length;
|
||||
// Synced (or already on the server) — either way they're done on this device.
|
||||
await removeQueued(scans.map(s => s.clientScanId));
|
||||
}
|
||||
const result = { accepted, conflicts, at: new Date().toISOString() };
|
||||
setLastSync(result);
|
||||
return result;
|
||||
} finally {
|
||||
syncingRef.current = false;
|
||||
setSyncing(false);
|
||||
refreshPending();
|
||||
}
|
||||
}, [token, refreshPending]);
|
||||
|
||||
// Sync as soon as the connection is back, and every 30 seconds while online.
|
||||
useEffect(() => {
|
||||
if (!online || !token) return;
|
||||
sync().catch(() => {});
|
||||
const t = setInterval(() => { if (navigator.onLine) sync().catch(() => {}); }, 30000);
|
||||
return () => clearInterval(t);
|
||||
}, [online, token, sync]);
|
||||
|
||||
const enable = useCallback(async (eventId: string, eventTitle: string, sectionId: string | null, sectionName: string | null) => {
|
||||
if (!token) throw new Error("Log in first.");
|
||||
const created = await apiFetch<{ id: string }>("/api/scan-locks", {
|
||||
method: "POST", authToken: token, body: { eventId, sectionId, deviceId: deviceId(), deviceName: deviceName() },
|
||||
});
|
||||
const snap = await apiFetch<{ snapshotAt: string; tickets: SnapshotTicket[] }>(`/api/scan-locks/${created.id}/snapshot?deviceId=${encodeURIComponent(deviceId())}`, { authToken: token });
|
||||
const next: OfflineLock = { lockId: created.id, eventId, eventTitle, sectionId, sectionName, snapshotAt: snap.snapshotAt };
|
||||
await startOffline(next, snap.tickets);
|
||||
setLock(next);
|
||||
return snap.tickets.length;
|
||||
}, [token]);
|
||||
|
||||
const release = useCallback(async () => {
|
||||
if (!lock || !token) return;
|
||||
await sync();
|
||||
if ((await queuedScans()).length > 0) throw new Error("Some scans haven't synced yet. Try again when you're online.");
|
||||
await apiFetch(`/api/scan-locks/${lock.lockId}`, { method: "DELETE", authToken: token, body: { deviceId: deviceId() } });
|
||||
await clearOffline();
|
||||
setLock(null);
|
||||
}, [lock, token, sync]);
|
||||
|
||||
const scanLocally = useCallback(async (ticket: SnapshotTicket, qty: number) => {
|
||||
if (!lock) throw new Error("Offline mode is not on.");
|
||||
await recordScan(lock.lockId, ticket, qty);
|
||||
await refreshPending();
|
||||
if (navigator.onLine) sync().catch(() => {});
|
||||
}, [lock, refreshPending, sync]);
|
||||
|
||||
return { lock, pending, online, syncing, lastSync, enable, release, sync, scanLocally, findTicket, deviceId };
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const minor = youth && isMinorOn(f.dateOfBirth, event?.startDate);
|
||||
const places = ticket.quantity || 1;
|
||||
const set = (k: keyof typeof f) => (e: React.ChangeEvent<HTMLInputElement>) => setF(prev => ({ ...prev, [k]: e.target.value }));
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body: Record<string, string> = { 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 (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRightLeft className="w-5 h-5 text-brand-600" />
|
||||
<h3 className="text-lg font-semibold">Transfer ticket</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{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.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">New holder's name</label>
|
||||
<input className={input} value={f.name} onChange={set("name")} required maxLength={100} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Email</label>
|
||||
<input type="email" className={input} value={f.email} onChange={set("email")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Phone (WhatsApp)</label>
|
||||
<input type="tel" className={input} value={f.phone} onChange={set("phone")} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">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.</p>
|
||||
{youth && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
|
||||
<input type="date" className={input} value={f.dateOfBirth} onChange={set("dateOfBirth")} required />
|
||||
</div>
|
||||
{minor && (
|
||||
<>
|
||||
<p className="text-xs text-amber-800 bg-amber-50 rounded p-2">They're under 18, so a parent or guardian will be asked for consent before they can check in.</p>
|
||||
<input className={input} placeholder="Parent or guardian's name" value={f.parentName} onChange={set("parentName")} required />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<input type="email" className={input} placeholder="Parent's email" value={f.parentEmail} onChange={set("parentEmail")} required />
|
||||
<input type="tel" className={input} placeholder="Parent's phone" value={f.parentPhone} onChange={set("parentPhone")} required />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
|
||||
<button type="submit" disabled={busy || !f.name.trim() || (!f.email.trim() && !f.phone.trim())} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{busy ? "Transferring…" : "Transfer"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
<GuideItem icon={Printer} title="Print, email, or WhatsApp" tone="green">
|
||||
Get your selected tickets onto your phone or paper before the event — as a printed page, an email, or a WhatsApp message.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ArrowRightLeft} title="Transfer a ticket" tone="violet">
|
||||
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.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -24,6 +24,7 @@ import { supervisorFormsHelpContent } from "./supervisor-forms";
|
||||
import { supervisorCouponsHelpContent } from "./supervisor-coupons";
|
||||
import { supervisorConsentHelpContent } from "./supervisor-consent";
|
||||
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
|
||||
import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans";
|
||||
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
|
||||
import { supervisorWhatsappAttendeesHelpContent } from "./supervisor-whatsapp-attendees";
|
||||
import { staffEventTicketsHelpContent } from "./staff-event-tickets";
|
||||
@@ -150,6 +151,11 @@ const REGISTRY: HelpRegistryEntry[] = [
|
||||
matches: pathname => pathname === "/dashboard/supervisor/payments",
|
||||
content: supervisorPaymentsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-offline-scans",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/offline-scans",
|
||||
content: supervisorOfflineScansHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-waitlist",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/waitlist",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { QrCode, Filter, CheckCircle2, ShieldAlert } from "lucide-react";
|
||||
import { QrCode, Filter, CheckCircle2, ShieldAlert, WifiOff } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -19,6 +19,9 @@ export const staffTicketScanningHelpContent: HelpContent = {
|
||||
<GuideItem icon={CheckCircle2} title="Outcomes" tone="green">
|
||||
You'll see Success (with redeemed/remaining counts), Already Used, or an Error if something's wrong with the ticket.
|
||||
</GuideItem>
|
||||
<GuideItem icon={WifiOff} title="Offline mode" tone="amber">
|
||||
Worried about the venue Wi-Fi? While you're online, pick the event and section and tap "Enable offline mode". The section is locked to your device and its tickets are downloaded, so you can keep scanning with no connection. Scans sync automatically when you're back online. Tickets bought after the download won't be on your list — send those people to the door desk. Tap "Release section" when you're done. Tip: add this page to your home screen so it opens offline.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ShieldAlert} title="Parental consent" tone="rose">
|
||||
On youth events, the confirm screen warns when a parent hasn't given consent yet (or declined). That person can't be checked in; a supervisor can override, and the override is recorded in the audit log.
|
||||
</GuideItem>
|
||||
|
||||
@@ -25,7 +25,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
|
||||
Per ticket-type totals with a stepper to check attendees in against the scan record.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Ticket} title="Tickets" tone="amber">
|
||||
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.
|
||||
</GuideItem>
|
||||
<GuideItem icon={RotateCcw} title="Refund" tone="rose">
|
||||
Issue a refund against an existing payment for the selected registration.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { WifiOff, Unlock, CheckCircle2, Lock } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for /dashboard/supervisor/offline-scans. */
|
||||
export const supervisorOfflineScansHelpContent: HelpContent = {
|
||||
title: "Offline scanning",
|
||||
subtitle: "Devices scanning without a connection, and what they synced.",
|
||||
tabs: [
|
||||
{
|
||||
key: "page",
|
||||
label: "This page",
|
||||
icon: WifiOff,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Lock} title="Locked sections" tone="brand">
|
||||
Each section (or the whole event) in offline mode is locked to one device, so two devices can never check in the same ticket while offline. You can see who holds each lock, when their ticket list was downloaded, and when they last synced.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Unlock} title="Force release" tone="amber">
|
||||
For a lost or flat device. The section is unlocked straight away. If the device reconnects later, its offline scans still sync and are flagged here.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CheckCircle2} title="Sync reports" tone="rose">
|
||||
Scans that need a person to look: tickets not found (e.g. transferred), cancelled registrations, tickets already used up, scans outside the locked section, or check-ins without parental consent. Mark each report reviewed once it's been dealt with.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -18,6 +18,25 @@ export type AuthContextValue = {
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
// The last profile the server confirmed, so a page reloaded with no connection (e.g. the
|
||||
// ticket scanner in offline mode) still knows who's signed in. The server checks the
|
||||
// token on every API call regardless, so this never grants anything by itself.
|
||||
const USER_CACHE_KEY = "hope_events_user_cache";
|
||||
function cacheUser(u: AuthUser | null) {
|
||||
try {
|
||||
if (u) localStorage.setItem(USER_CACHE_KEY, JSON.stringify(u));
|
||||
else localStorage.removeItem(USER_CACHE_KEY);
|
||||
} catch { /* storage unavailable */ }
|
||||
}
|
||||
function cachedUser(): AuthUser | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_CACHE_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
@@ -34,6 +53,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const me = await apiFetch<AuthUser>("/api/users/profile", { authToken: t });
|
||||
setToken(t);
|
||||
setUser(me);
|
||||
cacheUser(me);
|
||||
}
|
||||
} catch (e) {
|
||||
// Only wipe the stored token when the server explicitly rejects it (401).
|
||||
@@ -41,8 +61,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
// token stays in localStorage so the next page load can retry.
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
clearToken();
|
||||
cacheUser(null);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
} else {
|
||||
// No connection: carry on as the last confirmed user.
|
||||
const t = getToken();
|
||||
const cached = cachedUser();
|
||||
if (t && cached) {
|
||||
setToken(t);
|
||||
setUser(cached);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -57,6 +86,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
saveToken(token);
|
||||
setToken(token);
|
||||
setUser(loggedInUser);
|
||||
cacheUser(loggedInUser);
|
||||
return loggedInUser;
|
||||
}, []);
|
||||
|
||||
@@ -99,6 +129,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken();
|
||||
cacheUser(null);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
@@ -121,6 +121,7 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
|
||||
// end it and send them to log in, where they enrol.
|
||||
if (res.status === 401 && data?.code === "TWO_FACTOR_REQUIRED" && typeof window !== "undefined") {
|
||||
localStorage.removeItem("hope_events_token");
|
||||
localStorage.removeItem("hope_events_user_cache");
|
||||
if (!window.location.pathname.startsWith("/login")) {
|
||||
const back = window.location.pathname + window.location.search;
|
||||
window.location.href = `/login?reason=2fa&redirect=${encodeURIComponent(back)}`;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Offline scanning on this device: the ticket snapshot for a locked section and the
|
||||
* queue of scans waiting to sync, both kept in IndexedDB (via `idb`). The active lock
|
||||
* itself is in localStorage so the scanner page knows it's in offline mode after a
|
||||
* reload with no connection.
|
||||
*/
|
||||
import { openDB, type IDBPDatabase } from "idb";
|
||||
|
||||
export type SnapshotTicket = {
|
||||
id: string;
|
||||
qrCode: string;
|
||||
holder: string;
|
||||
bookedBy: string;
|
||||
option: string;
|
||||
variant: string | null;
|
||||
quantity: number;
|
||||
remaining: number;
|
||||
consentStatus: string;
|
||||
};
|
||||
|
||||
export type QueuedScan = { clientScanId: string; lockId: string; qrCode: string; qty: number; scannedAt: string };
|
||||
|
||||
export type OfflineLock = {
|
||||
lockId: string;
|
||||
eventId: string;
|
||||
eventTitle: string;
|
||||
sectionId: string | null;
|
||||
sectionName: string | null;
|
||||
snapshotAt: string;
|
||||
};
|
||||
|
||||
const DB_NAME = "hope-events-offline-scan";
|
||||
const LOCK_KEY = "hope_events_offline_lock";
|
||||
const DEVICE_KEY = "hope_events_scan_device";
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase> | null = null;
|
||||
function db() {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB(DB_NAME, 1, {
|
||||
upgrade(d) {
|
||||
d.createObjectStore("tickets", { keyPath: "qrCode" });
|
||||
d.createObjectStore("queue", { keyPath: "clientScanId" });
|
||||
},
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
/** A stable id for this browser, used to tie an offline lock to one device. */
|
||||
export function deviceId(): string {
|
||||
try {
|
||||
let id = localStorage.getItem(DEVICE_KEY);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(DEVICE_KEY, id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return "unknown-device";
|
||||
}
|
||||
}
|
||||
|
||||
/** A short, human name for this device ("Chrome on Android"). */
|
||||
export function deviceName(): string {
|
||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
||||
const browser = /Edg\//.test(ua) ? "Edge" : /Chrome\//.test(ua) ? "Chrome" : /Firefox\//.test(ua) ? "Firefox" : /Safari\//.test(ua) ? "Safari" : "Browser";
|
||||
const os = /Android/.test(ua) ? "Android" : /iPhone|iPad/.test(ua) ? "iOS" : /Windows/.test(ua) ? "Windows" : /Mac OS/.test(ua) ? "Mac" : "device";
|
||||
return `${browser} on ${os}`;
|
||||
}
|
||||
|
||||
export function getOfflineLock(): OfflineLock | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCK_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setOfflineLock(lock: OfflineLock | null) {
|
||||
try {
|
||||
if (lock) localStorage.setItem(LOCK_KEY, JSON.stringify(lock));
|
||||
else localStorage.removeItem(LOCK_KEY);
|
||||
} catch { /* storage unavailable */ }
|
||||
}
|
||||
|
||||
/** Keeps the downloaded ticket list and marks this device as in offline mode. */
|
||||
export async function startOffline(lock: OfflineLock, tickets: SnapshotTicket[]) {
|
||||
const d = await db();
|
||||
const tx = d.transaction("tickets", "readwrite");
|
||||
await tx.store.clear();
|
||||
for (const t of tickets) await tx.store.put(t);
|
||||
await tx.done;
|
||||
setOfflineLock(lock);
|
||||
}
|
||||
|
||||
export async function findTicket(qrCode: string): Promise<SnapshotTicket | undefined> {
|
||||
return (await db()).get("tickets", qrCode);
|
||||
}
|
||||
|
||||
/** Records a scan locally: lowers the ticket's remaining count and queues it for sync. */
|
||||
export async function recordScan(lockId: string, ticket: SnapshotTicket, qty: number): Promise<QueuedScan> {
|
||||
const d = await db();
|
||||
const scan: QueuedScan = { clientScanId: crypto.randomUUID(), lockId, qrCode: ticket.qrCode, qty, scannedAt: new Date().toISOString() };
|
||||
const tx = d.transaction(["tickets", "queue"], "readwrite");
|
||||
await tx.objectStore("tickets").put({ ...ticket, remaining: Math.max(0, ticket.remaining - qty) });
|
||||
await tx.objectStore("queue").put(scan);
|
||||
await tx.done;
|
||||
return scan;
|
||||
}
|
||||
|
||||
export async function queuedScans(): Promise<QueuedScan[]> {
|
||||
return (await db()).getAll("queue");
|
||||
}
|
||||
|
||||
export async function removeQueued(ids: string[]) {
|
||||
const d = await db();
|
||||
const tx = d.transaction("queue", "readwrite");
|
||||
for (const id of ids) await tx.store.delete(id);
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
/** Ends offline mode on this device: the ticket list is deleted (the queue must be empty). */
|
||||
export async function clearOffline() {
|
||||
const d = await db();
|
||||
await d.clear("tickets");
|
||||
setOfflineLock(null);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ 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.
|
||||
- **Self-service kiosk** — set up a tablet where visitors register themselves. Returning visitors are recognised by their email or phone number.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.16.0",
|
||||
"version": "1.18.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.16.0",
|
||||
"version": "1.18.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.16.0",
|
||||
"version": "1.18.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user