@@ -5,13 +5,95 @@ import path from 'path';
55import { fileURLToPath } from 'url' ;
66import prisma from '../config/db.js' ;
77import { verifyQRSignature } from '../utils/qr.util.js' ;
8- import { authenticate , checkEventAccess } from '../middleware/auth.middleware.js' ;
8+ import { authenticateToken } from '../middleware/auth.middleware.js' ;
99import { getR2ObjectBuffer , isR2TemplateRef } from '../utils/r2.util.js' ;
1010
1111const __filename = fileURLToPath ( import . meta. url ) ;
1212const __dirname = path . dirname ( __filename ) ;
1313
1414const router = express . Router ( ) ;
15+ const debugTicketVerify = ( ...args ) => {
16+ if ( process . env . DEBUG_TICKET_VERIFY === 'true' ) {
17+ console . log ( ...args ) ;
18+ }
19+ } ;
20+
21+ const UUID_PATTERN = / ^ [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 1 - 5 ] [ 0 - 9 a - f ] { 3 } - [ 8 9 a b ] [ 0 - 9 a - f ] { 3 } - [ 0 - 9 a - f ] { 12 } $ / i;
22+ const TICKET_PREFIX_PATTERN = / ^ [ 0 - 9 a - f ] { 6 , 12 } $ / i;
23+
24+ const ticketLookupSelect = `
25+ SELECT
26+ t.id,
27+ t.order_id AS "orderId",
28+ t.qr_payload AS "qrPayload",
29+ t.issued_at AS "issuedAt",
30+ t.revoked,
31+ t.valid_until AS "validUntil",
32+ t.scanned_at AS "scannedAt",
33+ t.checked_in_at AS "checkedInAt",
34+ r.form_response AS "formResponse",
35+ e.id AS "eventId",
36+ e.title AS "eventTitle",
37+ e.location AS "eventLocation",
38+ e.start_time AS "eventStartTime",
39+ e.organizer_id AS "organizerId"
40+ FROM tickets t
41+ INNER JOIN orders o ON o.id = t.order_id
42+ INNER JOIN registrations r ON r.id = o.registration_id
43+ INNER JOIN events e ON e.id = r.event_id
44+ ` ;
45+
46+ function mapTicketRow ( row ) {
47+ if ( ! row ) return null ;
48+
49+ return {
50+ id : row . id ,
51+ orderId : row . orderId ,
52+ qrPayload : row . qrPayload ,
53+ issuedAt : row . issuedAt ,
54+ revoked : row . revoked ,
55+ validUntil : row . validUntil ,
56+ scannedAt : row . scannedAt ,
57+ checkedInAt : row . checkedInAt ,
58+ order : {
59+ registration : {
60+ formResponse : row . formResponse ,
61+ event : {
62+ id : row . eventId ,
63+ title : row . eventTitle ,
64+ location : row . eventLocation ,
65+ startTime : row . eventStartTime ,
66+ organizerId : row . organizerId
67+ }
68+ }
69+ }
70+ } ;
71+ }
72+
73+ async function canScanTicket ( user , event ) {
74+ if ( user . role === 'ADMIN' ) {
75+ return { hasAccess : true } ;
76+ }
77+
78+ if ( event . organizerId === user . id ) {
79+ return { hasAccess : true } ;
80+ }
81+
82+ const teamMember = await prisma . teamMember . findUnique ( {
83+ where : { eventId_email : { eventId : event . id , email : user . email } } ,
84+ select : { role : true }
85+ } ) ;
86+
87+ if ( ! teamMember ) {
88+ return { hasAccess : false , error : 'Not authorized' } ;
89+ }
90+
91+ if ( ! [ 'SUPER_MANAGER' , 'MANAGER' , 'SCANNER' ] . includes ( teamMember . role ) ) {
92+ return { hasAccess : false , error : 'Insufficient permissions' } ;
93+ }
94+
95+ return { hasAccess : true } ;
96+ }
1597
1698const toNormalizedPayload = ( input ) => {
1799 if ( ! input ) return null ;
@@ -94,65 +176,69 @@ const toNormalizedPayload = (input) => {
94176async function findTicketByIdOrPrefix ( ticketId ) {
95177 if ( ! ticketId ) return null ;
96178 const cleaned = ticketId . trim ( ) . toLowerCase ( ) ;
179+ const isFullId = UUID_PATTERN . test ( cleaned ) ;
180+ const isPrefix = TICKET_PREFIX_PATTERN . test ( cleaned ) ;
181+
182+ if ( ! isFullId && ! isPrefix ) {
183+ return null ;
184+ }
97185
98186 // Try exact match first
99- let ticket = await prisma . ticket . findUnique ( {
100- where : { id : cleaned } ,
101- include : { order : { include : { registration : { include : { event : true } } } } }
102- } ) ;
103- if ( ticket ) return ticket ;
187+ if ( isFullId ) {
188+ const rows = await prisma . $queryRawUnsafe ( ` ${ ticketLookupSelect } WHERE t.id = $1 LIMIT 1` , cleaned ) ;
189+ const ticket = mapTicketRow ( rows [ 0 ] ) ;
190+ if ( ticket ) return ticket ;
191+ }
104192
105193 // Try prefix match (short IDs like "2FBF033A" → first 8 chars of UUID)
106- if ( cleaned . length >= 6 && cleaned . length <= 12 && ! cleaned . includes ( '-' ) ) {
107- ticket = await prisma . ticket . findFirst ( {
108- where : { id : { startsWith : cleaned } } ,
109- include : { order : { include : { registration : { include : { event : true } } } } }
110- } ) ;
194+ if ( isPrefix ) {
195+ const rows = await prisma . $queryRawUnsafe ( `${ ticketLookupSelect } WHERE t.id LIKE $1 LIMIT 1` , `${ cleaned } %` ) ;
196+ const ticket = mapTicketRow ( rows [ 0 ] ) ;
111197 if ( ticket ) return ticket ;
112198 }
113199
114200 return null ;
115201}
116202
117- // Verify ticket (for scanning at venue) - requires authentication
118- router . post ( '/verify' , authenticate , async ( req , res ) => {
203+ // Verify ticket (for scanning at venue) - uses JWT-only auth for fast gate checks.
204+ router . post ( '/verify' , authenticateToken , async ( req , res ) => {
119205 try {
120206 const { qrPayload } = req . body ;
121207
122208 if ( ! qrPayload ) {
123209 return res . status ( 400 ) . json ( { error : 'QR payload required' } ) ;
124210 }
125211
126- console . log ( '[verify] Raw qrPayload type:' , typeof qrPayload , 'length:' , String ( qrPayload ) . length ) ;
212+ debugTicketVerify ( '[verify] Raw qrPayload type:' , typeof qrPayload , 'length:' , String ( qrPayload ) . length ) ;
127213
128214 // Parse QR payload (supports JSON, URL-encoded JSON, base64 JSON, URL query payloads)
129215 const payload = toNormalizedPayload ( qrPayload ) ;
130216
131217 let ticket = null ;
132218
133219 if ( payload && payload . ticketId ) {
134- console . log ( '[verify] Parsed ticketId:' , payload . ticketId ) ;
220+ debugTicketVerify ( '[verify] Parsed ticketId:' , payload . ticketId ) ;
135221 ticket = await findTicketByIdOrPrefix ( payload . ticketId ) ;
136222 }
137223
138224 // Fallback: treat raw qrPayload as a plain ticket ID string
139225 if ( ! ticket && typeof qrPayload === 'string' ) {
140226 const rawId = qrPayload . trim ( ) . replace ( / ^ \uFEFF / , '' ) . replace ( / [ ^ a - f A - F 0 - 9 - ] / g, '' ) ;
141227 if ( rawId . length >= 6 ) {
142- console . log ( '[verify] Trying raw string as ticketId:' , rawId ) ;
228+ debugTicketVerify ( '[verify] Trying raw string as ticketId:' , rawId ) ;
143229 ticket = await findTicketByIdOrPrefix ( rawId ) ;
144230 }
145231 }
146232
147233 if ( ! ticket ) {
148- console . log ( '[verify] Ticket not found for payload:' , JSON . stringify ( payload ) ) ;
234+ debugTicketVerify ( '[verify] Ticket not found for payload:' , JSON . stringify ( payload ) ) ;
149235 return res . status ( 404 ) . json ( {
150236 valid : false ,
151237 error : 'Ticket not found'
152238 } ) ;
153239 }
154240
155- console . log ( '[verify] Found ticket:' , ticket . id ) ;
241+ debugTicketVerify ( '[verify] Found ticket:' , ticket . id ) ;
156242
157243 // Signature verification — multiple fallback strategies
158244 let storedPayload = null ;
@@ -178,7 +264,7 @@ router.post('/verify', authenticate, async (req, res) => {
178264 // In dev mode ALWAYS valid; in production accept any valid fallback
179265 const isValid = isDev ? true : ( hasValidHmac || matchesStoredPayload || matchesTicketIdentity ) ;
180266
181- console . log ( '[verify] Sig check:' , { isDev, hasValidHmac, matchesStoredPayload, matchesTicketIdentity, isValid } ) ;
267+ debugTicketVerify ( '[verify] Sig check:' , { isDev, hasValidHmac, matchesStoredPayload, matchesTicketIdentity, isValid } ) ;
182268
183269 if ( ! isValid ) {
184270 return res . status ( 400 ) . json ( {
@@ -188,8 +274,8 @@ router.post('/verify', authenticate, async (req, res) => {
188274 }
189275
190276 // Check if user has access to scan this event's tickets
191- const eventId = ticket . order . registration . event . id ;
192- const accessCheck = await checkEventAccess ( req . user , eventId , [ 'SUPER_MANAGER' , 'MANAGER' , 'SCANNER' ] ) ;
277+ const event = ticket . order . registration . event ;
278+ const accessCheck = await canScanTicket ( req . user , event ) ;
193279
194280 if ( ! accessCheck . hasAccess ) {
195281 return res . status ( 403 ) . json ( {
@@ -230,14 +316,34 @@ router.post('/verify', authenticate, async (req, res) => {
230316
231317 // Mark ticket as scanned (set both fields for compatibility)
232318 const now = new Date ( ) ;
233- await prisma . ticket . update ( {
234- where : { id : ticket . id } ,
235- data : {
319+ const updateResult = await prisma . ticket . updateMany ( {
320+ where : {
321+ id : ticket . id ,
322+ scannedAt : null ,
323+ checkedInAt : null
324+ } ,
325+ data : {
236326 scannedAt : now ,
237- checkedInAt : now
327+ checkedInAt : now ,
328+ checkedInBy : req . user . id
238329 }
239330 } ) ;
240331
332+ if ( updateResult . count === 0 ) {
333+ const currentTicket = await prisma . ticket . findUnique ( {
334+ where : { id : ticket . id } ,
335+ select : { scannedAt : true , checkedInAt : true }
336+ } ) ;
337+
338+ return res . status ( 400 ) . json ( {
339+ valid : false ,
340+ alreadyScanned : true ,
341+ error : 'Ticket already used' ,
342+ scannedAt : currentTicket ?. scannedAt || currentTicket ?. checkedInAt || now ,
343+ attendee : ticket . order . registration . formResponse
344+ } ) ;
345+ }
346+
241347 res . json ( {
242348 valid : true ,
243349 ticket : {
0 commit comments