Add offline ticket scanning
Staff can lock a section (or the whole event) to their device, download its tickets and keep scanning with no connection. Scans are queued in IndexedDB and synced idempotently when back online; conflicts go into a sync report for supervisors, who can also force-release a lost device. The server now validates scan sections and refuses scans in a scope locked to another device. Adds a web app manifest and a service worker for the scanner page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -103,8 +103,11 @@ model User {
|
||||
couponsApplied CouponRedemption[] @relation("CouponRedemptionAppliedBy")
|
||||
|
||||
attendeeProfiles Attendee[]
|
||||
heldTickets Ticket[] @relation("TicketHolder")
|
||||
heldTickets Ticket[] @relation("TicketHolder")
|
||||
waitlistEntries WaitlistEntry[]
|
||||
scanLocks ScanLock[] @relation("ScanLockOwner")
|
||||
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
|
||||
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
|
||||
|
||||
twoFactorChallenges TwoFactorChallenge[]
|
||||
trustedDevices TrustedDevice[]
|
||||
@@ -175,6 +178,7 @@ model Event {
|
||||
coupons CouponEvent[]
|
||||
consentRequests ConsentRequest[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
scanLocks ScanLock[]
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@ -370,13 +374,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 +472,10 @@ enum AdminAuditAction {
|
||||
consent_override
|
||||
consent_rerequested
|
||||
medical_list_viewed
|
||||
scan_lock_acquired
|
||||
scan_lock_released
|
||||
scan_lock_force_released
|
||||
offline_sync
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -601,6 +616,7 @@ model Section {
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
allowedOptions SectionOption[]
|
||||
scanLocks ScanLock[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
@@ -1085,3 +1101,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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user