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:
Michilis
2026-02-02 00:45:12 +00:00
parent b0cbaa60f0
commit 9410e83b89
28 changed files with 1930 additions and 85 deletions

View File

@@ -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)