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

@@ -28,6 +28,8 @@ const baseEventSchema = z.object({
titleEs: z.string().optional().nullable(),
description: z.string().min(1),
descriptionEs: z.string().optional().nullable(),
shortDescription: z.string().max(300).optional().nullable(),
shortDescriptionEs: z.string().max(300).optional().nullable(),
startDatetime: z.string(),
endDatetime: z.string().optional().nullable(),
location: z.string().min(1),
@@ -315,6 +317,8 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
titleEs: existing.titleEs ? `${existing.titleEs} (Copia)` : null,
description: existing.description,
descriptionEs: existing.descriptionEs,
shortDescription: existing.shortDescription,
shortDescriptionEs: existing.shortDescriptionEs,
startDatetime: existing.startDatetime,
endDatetime: existing.endDatetime,
location: existing.location,

View File

@@ -0,0 +1,144 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, siteSettings } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
interface UserContext {
id: string;
email: string;
name: string;
role: string;
}
const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Validation schema for updating site settings
const updateSiteSettingsSchema = z.object({
timezone: z.string().optional(),
siteName: z.string().optional(),
siteDescription: z.string().optional().nullable(),
siteDescriptionEs: z.string().optional().nullable(),
contactEmail: z.string().email().optional().nullable().or(z.literal('')),
contactPhone: z.string().optional().nullable(),
facebookUrl: z.string().url().optional().nullable().or(z.literal('')),
instagramUrl: z.string().url().optional().nullable().or(z.literal('')),
twitterUrl: z.string().url().optional().nullable().or(z.literal('')),
linkedinUrl: z.string().url().optional().nullable().or(z.literal('')),
maintenanceMode: z.boolean().optional(),
maintenanceMessage: z.string().optional().nullable(),
maintenanceMessageEs: z.string().optional().nullable(),
});
// Get site settings (public - needed for frontend timezone)
siteSettingsRouter.get('/', async (c) => {
const settings = await (db as any).select().from(siteSettings).limit(1).get();
if (!settings) {
// Return default settings if none exist
return c.json({
settings: {
timezone: 'America/Asuncion',
siteName: 'Spanglish',
siteDescription: null,
siteDescriptionEs: null,
contactEmail: null,
contactPhone: null,
facebookUrl: null,
instagramUrl: null,
twitterUrl: null,
linkedinUrl: null,
maintenanceMode: false,
maintenanceMessage: null,
maintenanceMessageEs: null,
},
});
}
return c.json({ settings });
});
// Get available timezones
siteSettingsRouter.get('/timezones', async (c) => {
// Common timezones for Americas (especially relevant for Paraguay)
const timezones = [
{ value: 'America/Asuncion', label: 'Paraguay (Asunción) - UTC-4/-3' },
{ value: 'America/Sao_Paulo', label: 'Brazil (São Paulo) - UTC-3' },
{ value: 'America/Buenos_Aires', label: 'Argentina (Buenos Aires) - UTC-3' },
{ value: 'America/Santiago', label: 'Chile (Santiago) - UTC-4/-3' },
{ value: 'America/Lima', label: 'Peru (Lima) - UTC-5' },
{ value: 'America/Bogota', label: 'Colombia (Bogotá) - UTC-5' },
{ value: 'America/Caracas', label: 'Venezuela (Caracas) - UTC-4' },
{ value: 'America/La_Paz', label: 'Bolivia (La Paz) - UTC-4' },
{ value: 'America/Montevideo', label: 'Uruguay (Montevideo) - UTC-3' },
{ value: 'America/New_York', label: 'US Eastern - UTC-5/-4' },
{ value: 'America/Chicago', label: 'US Central - UTC-6/-5' },
{ value: 'America/Denver', label: 'US Mountain - UTC-7/-6' },
{ value: 'America/Los_Angeles', label: 'US Pacific - UTC-8/-7' },
{ value: 'America/Mexico_City', label: 'Mexico (Mexico City) - UTC-6/-5' },
{ value: 'Europe/London', label: 'UK (London) - UTC+0/+1' },
{ value: 'Europe/Madrid', label: 'Spain (Madrid) - UTC+1/+2' },
{ value: 'Europe/Paris', label: 'France (Paris) - UTC+1/+2' },
{ value: 'Europe/Berlin', label: 'Germany (Berlin) - UTC+1/+2' },
{ value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
];
return c.json({ timezones });
});
// Update site settings (admin only)
siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSiteSettingsSchema), async (c) => {
const data = c.req.valid('json');
const user = c.get('user');
const now = getNow();
// Check if settings exist
const existing = await (db as any).select().from(siteSettings).limit(1).get();
if (!existing) {
// Create new settings record
const id = generateId();
const newSettings = {
id,
timezone: data.timezone || 'America/Asuncion',
siteName: data.siteName || 'Spanglish',
siteDescription: data.siteDescription || null,
siteDescriptionEs: data.siteDescriptionEs || null,
contactEmail: data.contactEmail || null,
contactPhone: data.contactPhone || null,
facebookUrl: data.facebookUrl || null,
instagramUrl: data.instagramUrl || null,
twitterUrl: data.twitterUrl || null,
linkedinUrl: data.linkedinUrl || null,
maintenanceMode: data.maintenanceMode || false,
maintenanceMessage: data.maintenanceMessage || null,
maintenanceMessageEs: data.maintenanceMessageEs || null,
updatedAt: now,
updatedBy: user.id,
};
await (db as any).insert(siteSettings).values(newSettings);
return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
}
// Update existing settings
const updateData = {
...data,
updatedAt: now,
updatedBy: user.id,
};
await (db as any)
.update(siteSettings)
.set(updateData)
.where(eq((siteSettings as any).id, existing.id));
const updated = await (db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id)).get();
return c.json({ settings: updated, message: 'Settings updated successfully' });
});
export default siteSettingsRouter;

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)