Skip to content

Commit 8677fe4

Browse files
committed
Improve admin UI and scanner flow
1 parent c96243c commit 8677fe4

27 files changed

Lines changed: 1641 additions & 1069 deletions

backend/src/middleware/auth.middleware.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,26 @@ export const authenticate = async (req, res, next) => {
2626
}
2727
};
2828

29+
export const authenticateToken = (req, res, next) => {
30+
try {
31+
const token = req.headers.authorization?.split(' ')[1];
32+
33+
if (!token) {
34+
return res.status(401).json({ error: 'No token provided' });
35+
}
36+
37+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
38+
req.user = {
39+
id: decoded.userId,
40+
email: decoded.email,
41+
role: decoded.role
42+
};
43+
next();
44+
} catch {
45+
res.status(401).json({ error: 'Invalid or expired token' });
46+
}
47+
};
48+
2949
export const requireAdmin = (req, res, next) => {
3050
if (req.user.role !== 'ADMIN') {
3151
return res.status(403).json({ error: 'Admin access required' });

backend/src/routes/ticket.routes.js

Lines changed: 131 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,95 @@ import path from 'path';
55
import { fileURLToPath } from 'url';
66
import prisma from '../config/db.js';
77
import { verifyQRSignature } from '../utils/qr.util.js';
8-
import { authenticate, checkEventAccess } from '../middleware/auth.middleware.js';
8+
import { authenticateToken } from '../middleware/auth.middleware.js';
99
import { getR2ObjectBuffer, isR2TemplateRef } from '../utils/r2.util.js';
1010

1111
const __filename = fileURLToPath(import.meta.url);
1212
const __dirname = path.dirname(__filename);
1313

1414
const 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-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
22+
const TICKET_PREFIX_PATTERN = /^[0-9a-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

1698
const toNormalizedPayload = (input) => {
1799
if (!input) return null;
@@ -94,65 +176,69 @@ const toNormalizedPayload = (input) => {
94176
async 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-fA-F0-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: {

backend/src/server.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import 'dotenv/config';
12
import express from 'express';
23
import cors from 'cors';
34
import helmet from 'helmet';
4-
import dotenv from 'dotenv';
55
import rateLimit from 'express-rate-limit';
66

77
// Import routes
@@ -21,8 +21,6 @@ import teamRoutes from './routes/team.routes.js';
2121
import featureRoutes from './routes/feature.routes.js';
2222
import walletRoutes from './routes/wallet.routes.js';
2323

24-
dotenv.config();
25-
2624
const app = express();
2725
const PORT = process.env.PORT || 5000;
2826

0 commit comments

Comments
 (0)