Add ticket system with QR scanner and PDF generation
- Add ticket validation and check-in API endpoints - Add PDF ticket generation with QR codes (pdfkit) - Add admin QR scanner page with camera support - Add admin site settings page - Update email templates with PDF ticket download link - Add checked_in_by_admin_id field for audit tracking - Update booking success page with ticket download - Various UI improvements to events and booking pages
This commit is contained in:
@@ -7,6 +7,7 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
|
||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { generateTicketPDF } from '../lib/pdf.js';
|
||||
|
||||
const ticketsRouter = new Hono();
|
||||
|
||||
@@ -247,6 +248,70 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// Download ticket as PDF
|
||||
ticketsRouter.get('/:id/pdf', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const user = await getAuthUser(c);
|
||||
|
||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
// Check authorization - must be ticket owner or admin
|
||||
if (user) {
|
||||
const isAdmin = ['admin', 'organizer', 'staff'].includes(user.role);
|
||||
const isOwner = user.id === ticket.userId;
|
||||
|
||||
if (!isAdmin && !isOwner) {
|
||||
return c.json({ error: 'Unauthorized' }, 403);
|
||||
}
|
||||
} else {
|
||||
// Allow unauthenticated access via ticket ID for email links
|
||||
// The ticket ID itself serves as a secure token (UUID)
|
||||
}
|
||||
|
||||
// Only generate PDF for confirmed or checked-in tickets
|
||||
if (!['confirmed', 'checked_in'].includes(ticket.status)) {
|
||||
return c.json({ error: 'Ticket is not confirmed' }, 400);
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get();
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const pdfBuffer = await generateTicketPDF({
|
||||
id: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
event: {
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
endDatetime: event.endDatetime,
|
||||
location: event.location,
|
||||
locationUrl: event.locationUrl,
|
||||
},
|
||||
});
|
||||
|
||||
// Set response headers for PDF download
|
||||
return new Response(new Uint8Array(pdfBuffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="spanglish-ticket-${ticket.qrCode}.pdf"`,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('PDF generation error:', error);
|
||||
return c.json({ error: 'Failed to generate PDF' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Get ticket by ID
|
||||
ticketsRouter.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
@@ -301,9 +366,108 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
|
||||
return c.json({ ticket: updated });
|
||||
});
|
||||
|
||||
// Validate ticket by QR code (for scanner)
|
||||
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const { code, eventId } = body;
|
||||
|
||||
if (!code) {
|
||||
return c.json({ error: 'Code is required' }, 400);
|
||||
}
|
||||
|
||||
// Try to find ticket by QR code or ID
|
||||
let ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).qrCode, code))
|
||||
.get();
|
||||
|
||||
// If not found by QR, try by ID
|
||||
if (!ticket) {
|
||||
ticket = await (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, code))
|
||||
.get();
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return c.json({
|
||||
valid: false,
|
||||
error: 'Ticket not found',
|
||||
status: 'invalid',
|
||||
});
|
||||
}
|
||||
|
||||
// If eventId is provided, verify the ticket is for that event
|
||||
if (eventId && ticket.eventId !== eventId) {
|
||||
return c.json({
|
||||
valid: false,
|
||||
error: 'Ticket is for a different event',
|
||||
status: 'wrong_event',
|
||||
});
|
||||
}
|
||||
|
||||
// Get event details
|
||||
const event = await (db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
.get();
|
||||
|
||||
// Determine validity status
|
||||
let validityStatus = 'invalid';
|
||||
let canCheckIn = false;
|
||||
|
||||
if (ticket.status === 'cancelled') {
|
||||
validityStatus = 'cancelled';
|
||||
} else if (ticket.status === 'pending') {
|
||||
validityStatus = 'pending_payment';
|
||||
} else if (ticket.status === 'checked_in') {
|
||||
validityStatus = 'already_checked_in';
|
||||
} else if (ticket.status === 'confirmed') {
|
||||
validityStatus = 'valid';
|
||||
canCheckIn = true;
|
||||
}
|
||||
|
||||
// Get admin who checked in (if applicable)
|
||||
let checkedInBy = null;
|
||||
if (ticket.checkedInByAdminId) {
|
||||
const admin = await (db as any)
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq((users as any).id, ticket.checkedInByAdminId))
|
||||
.get();
|
||||
checkedInBy = admin ? admin.name : null;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
valid: validityStatus === 'valid',
|
||||
status: validityStatus,
|
||||
canCheckIn,
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
qrCode: ticket.qrCode,
|
||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeePhone: ticket.attendeePhone,
|
||||
status: ticket.status,
|
||||
checkinAt: ticket.checkinAt,
|
||||
checkedInBy,
|
||||
},
|
||||
event: event ? {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
location: event.location,
|
||||
} : null,
|
||||
});
|
||||
});
|
||||
|
||||
// Check-in ticket
|
||||
ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const adminUser = (c as any).get('user');
|
||||
|
||||
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
|
||||
@@ -319,14 +483,33 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']),
|
||||
return c.json({ error: 'Ticket must be confirmed before check-in' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'checked_in', checkinAt: getNow() })
|
||||
.set({
|
||||
status: 'checked_in',
|
||||
checkinAt: now,
|
||||
checkedInByAdminId: adminUser?.id || null,
|
||||
})
|
||||
.where(eq((tickets as any).id, id));
|
||||
|
||||
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
||||
|
||||
return c.json({ ticket: updated, message: 'Check-in successful' });
|
||||
// Get event for response
|
||||
const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get();
|
||||
|
||||
return c.json({
|
||||
ticket: {
|
||||
...updated,
|
||||
attendeeName: `${updated.attendeeFirstName} ${updated.attendeeLastName || ''}`.trim(),
|
||||
},
|
||||
event: event ? {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
} : null,
|
||||
message: 'Check-in successful'
|
||||
});
|
||||
});
|
||||
|
||||
// Mark payment as received (for cash payments - admin only)
|
||||
|
||||
Reference in New Issue
Block a user