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:
@@ -23,7 +23,9 @@
|
||||
"jose": "^5.4.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.12.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -31,7 +33,9 @@
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"drizzle-kit": "^0.22.8",
|
||||
"tsx": "^4.15.7",
|
||||
"typescript": "^5.5.2"
|
||||
|
||||
@@ -74,6 +74,8 @@ async function migrate() {
|
||||
title_es TEXT,
|
||||
description TEXT NOT NULL,
|
||||
description_es TEXT,
|
||||
short_description TEXT,
|
||||
short_description_es TEXT,
|
||||
start_datetime TEXT NOT NULL,
|
||||
end_datetime TEXT,
|
||||
location TEXT NOT NULL,
|
||||
@@ -98,6 +100,14 @@ async function migrate() {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Add short description columns to events
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description_es TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -151,6 +161,11 @@ async function migrate() {
|
||||
`);
|
||||
} catch (e) { /* migration may have already run */ }
|
||||
|
||||
// Migration: Add checked_in_by_admin_id column to tickets
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id TEXT REFERENCES users(id)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
||||
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||
|
||||
@@ -347,6 +362,28 @@ async function migrate() {
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Site settings table
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS site_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
timezone TEXT NOT NULL DEFAULT 'America/Asuncion',
|
||||
site_name TEXT NOT NULL DEFAULT 'Spanglish',
|
||||
site_description TEXT,
|
||||
site_description_es TEXT,
|
||||
contact_email TEXT,
|
||||
contact_phone TEXT,
|
||||
facebook_url TEXT,
|
||||
instagram_url TEXT,
|
||||
twitter_url TEXT,
|
||||
linkedin_url TEXT,
|
||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||
maintenance_message TEXT,
|
||||
maintenance_message_es TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
} else {
|
||||
// PostgreSQL migrations
|
||||
await (db as any).execute(sql`
|
||||
@@ -415,6 +452,8 @@ async function migrate() {
|
||||
title_es VARCHAR(255),
|
||||
description TEXT NOT NULL,
|
||||
description_es TEXT,
|
||||
short_description VARCHAR(300),
|
||||
short_description_es VARCHAR(300),
|
||||
start_datetime TIMESTAMP NOT NULL,
|
||||
end_datetime TIMESTAMP,
|
||||
location VARCHAR(500) NOT NULL,
|
||||
@@ -439,6 +478,14 @@ async function migrate() {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Add short description columns to events
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -462,6 +509,11 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN attendee_ruc VARCHAR(15)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Add checked_in_by_admin_id column to tickets
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id UUID REFERENCES users(id)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
@@ -642,6 +694,28 @@ async function migrate() {
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Site settings table
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS site_settings (
|
||||
id UUID PRIMARY KEY,
|
||||
timezone VARCHAR(100) NOT NULL DEFAULT 'America/Asuncion',
|
||||
site_name VARCHAR(255) NOT NULL DEFAULT 'Spanglish',
|
||||
site_description TEXT,
|
||||
site_description_es TEXT,
|
||||
contact_email VARCHAR(255),
|
||||
contact_phone VARCHAR(50),
|
||||
facebook_url VARCHAR(500),
|
||||
instagram_url VARCHAR(500),
|
||||
twitter_url VARCHAR(500),
|
||||
linkedin_url VARCHAR(500),
|
||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||
maintenance_message TEXT,
|
||||
maintenance_message_es TEXT,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
updated_by UUID REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
console.log('Migrations completed successfully!');
|
||||
|
||||
@@ -66,6 +66,8 @@ export const sqliteEvents = sqliteTable('events', {
|
||||
titleEs: text('title_es'),
|
||||
description: text('description').notNull(),
|
||||
descriptionEs: text('description_es'),
|
||||
shortDescription: text('short_description'),
|
||||
shortDescriptionEs: text('short_description_es'),
|
||||
startDatetime: text('start_datetime').notNull(),
|
||||
endDatetime: text('end_datetime'),
|
||||
location: text('location').notNull(),
|
||||
@@ -93,6 +95,7 @@ export const sqliteTickets = sqliteTable('tickets', {
|
||||
preferredLanguage: text('preferred_language'),
|
||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
|
||||
checkinAt: text('checkin_at'),
|
||||
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
||||
qrCode: text('qr_code'),
|
||||
adminNote: text('admin_note'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
@@ -246,6 +249,32 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Site Settings table for global website configuration
|
||||
export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||
id: text('id').primaryKey(),
|
||||
// Timezone configuration
|
||||
timezone: text('timezone').notNull().default('America/Asuncion'),
|
||||
// Site info
|
||||
siteName: text('site_name').notNull().default('Spanglish'),
|
||||
siteDescription: text('site_description'),
|
||||
siteDescriptionEs: text('site_description_es'),
|
||||
// Contact info
|
||||
contactEmail: text('contact_email'),
|
||||
contactPhone: text('contact_phone'),
|
||||
// Social links (can also be stored here as fallback)
|
||||
facebookUrl: text('facebook_url'),
|
||||
instagramUrl: text('instagram_url'),
|
||||
twitterUrl: text('twitter_url'),
|
||||
linkedinUrl: text('linkedin_url'),
|
||||
// Other settings
|
||||
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
|
||||
maintenanceMessage: text('maintenance_message'),
|
||||
maintenanceMessageEs: text('maintenance_message_es'),
|
||||
// Metadata
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||
});
|
||||
|
||||
// ==================== PostgreSQL Schema ====================
|
||||
export const pgUsers = pgTable('users', {
|
||||
id: uuid('id').primaryKey(),
|
||||
@@ -308,6 +337,8 @@ export const pgEvents = pgTable('events', {
|
||||
titleEs: varchar('title_es', { length: 255 }),
|
||||
description: pgText('description').notNull(),
|
||||
descriptionEs: pgText('description_es'),
|
||||
shortDescription: varchar('short_description', { length: 300 }),
|
||||
shortDescriptionEs: varchar('short_description_es', { length: 300 }),
|
||||
startDatetime: timestamp('start_datetime').notNull(),
|
||||
endDatetime: timestamp('end_datetime'),
|
||||
location: varchar('location', { length: 500 }).notNull(),
|
||||
@@ -335,6 +366,7 @@ export const pgTickets = pgTable('tickets', {
|
||||
preferredLanguage: varchar('preferred_language', { length: 10 }),
|
||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||
checkinAt: timestamp('checkin_at'),
|
||||
checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in
|
||||
qrCode: varchar('qr_code', { length: 255 }),
|
||||
adminNote: pgText('admin_note'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
@@ -480,6 +512,32 @@ export const pgEmailSettings = pgTable('email_settings', {
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Site Settings table for global website configuration
|
||||
export const pgSiteSettings = pgTable('site_settings', {
|
||||
id: uuid('id').primaryKey(),
|
||||
// Timezone configuration
|
||||
timezone: varchar('timezone', { length: 100 }).notNull().default('America/Asuncion'),
|
||||
// Site info
|
||||
siteName: varchar('site_name', { length: 255 }).notNull().default('Spanglish'),
|
||||
siteDescription: pgText('site_description'),
|
||||
siteDescriptionEs: pgText('site_description_es'),
|
||||
// Contact info
|
||||
contactEmail: varchar('contact_email', { length: 255 }),
|
||||
contactPhone: varchar('contact_phone', { length: 50 }),
|
||||
// Social links
|
||||
facebookUrl: varchar('facebook_url', { length: 500 }),
|
||||
instagramUrl: varchar('instagram_url', { length: 500 }),
|
||||
twitterUrl: varchar('twitter_url', { length: 500 }),
|
||||
linkedinUrl: varchar('linkedin_url', { length: 500 }),
|
||||
// Other settings
|
||||
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
|
||||
maintenanceMessage: pgText('maintenance_message'),
|
||||
maintenanceMessageEs: pgText('maintenance_message_es'),
|
||||
// Metadata
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
});
|
||||
|
||||
// Export the appropriate schema based on DB_TYPE
|
||||
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
|
||||
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
|
||||
@@ -497,6 +555,7 @@ export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverr
|
||||
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
||||
export const userSessions = dbType === 'postgres' ? pgUserSessions : sqliteUserSessions;
|
||||
export const invoices = dbType === 'postgres' ? pgInvoices : sqliteInvoices;
|
||||
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
||||
|
||||
// Type exports
|
||||
export type User = typeof sqliteUsers.$inferSelect;
|
||||
@@ -523,3 +582,5 @@ export type UserSession = typeof sqliteUserSessions.$inferSelect;
|
||||
export type NewUserSession = typeof sqliteUserSessions.$inferInsert;
|
||||
export type Invoice = typeof sqliteInvoices.$inferSelect;
|
||||
export type NewInvoice = typeof sqliteInvoices.$inferInsert;
|
||||
export type SiteSettings = typeof sqliteSiteSettings.$inferSelect;
|
||||
export type NewSiteSettings = typeof sqliteSiteSettings.$inferInsert;
|
||||
|
||||
@@ -19,6 +19,7 @@ import lnbitsRoutes from './routes/lnbits.js';
|
||||
import emailsRoutes from './routes/emails.js';
|
||||
import paymentOptionsRoutes from './routes/payment-options.js';
|
||||
import dashboardRoutes from './routes/dashboard.js';
|
||||
import siteSettingsRoutes from './routes/site-settings.js';
|
||||
import emailService from './lib/email.js';
|
||||
|
||||
const app = new Hono();
|
||||
@@ -1712,6 +1713,7 @@ app.route('/api/lnbits', lnbitsRoutes);
|
||||
app.route('/api/emails', emailsRoutes);
|
||||
app.route('/api/payment-options', paymentOptionsRoutes);
|
||||
app.route('/api/dashboard', dashboardRoutes);
|
||||
app.route('/api/site-settings', siteSettingsRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.notFound((c) => {
|
||||
|
||||
@@ -548,6 +548,10 @@ export const emailService = {
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
// Generate ticket PDF URL
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
const ticketPdfUrl = `${apiUrl}/api/tickets/${ticket.id}/pdf`;
|
||||
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
return this.sendTemplateEmail({
|
||||
templateSlug: 'booking-confirmation',
|
||||
@@ -560,6 +564,7 @@ export const emailService = {
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
qrCode: ticket.qrCode || '',
|
||||
ticketPdfUrl,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
|
||||
@@ -35,6 +35,7 @@ export const bookingVariables: EmailVariable[] = [
|
||||
{ name: 'attendeeEmail', description: 'Attendee email', example: 'john@example.com' },
|
||||
{ name: 'ticketId', description: 'Unique ticket ID', example: 'TKT-ABC123' },
|
||||
{ name: 'qrCode', description: 'QR code for check-in', example: 'data:image/png;base64,...' },
|
||||
{ name: 'ticketPdfUrl', description: 'URL to download ticket PDF', example: 'https://api.spanglish.com/api/tickets/abc123/pdf' },
|
||||
{ name: 'eventTitle', description: 'Event title', example: 'Spanglish Night - January Edition' },
|
||||
{ name: 'eventDate', description: 'Event date formatted', example: 'January 28, 2026' },
|
||||
{ name: 'eventTime', description: 'Event time', example: '7:00 PM' },
|
||||
@@ -228,18 +229,17 @@ export const defaultTemplates: DefaultTemplate[] = [
|
||||
|
||||
<div class="ticket-box">
|
||||
<p>Your Ticket ID</p>
|
||||
<p class="ticket-id">{{ticketId}}</p>
|
||||
<p class="ticket-id">{{qrCode}}</p>
|
||||
</div>
|
||||
|
||||
{{#if qrCode}}
|
||||
<div class="qr-code">
|
||||
<p><strong>Show this QR code at check-in:</strong></p>
|
||||
<img src="{{qrCode}}" alt="Check-in QR Code" />
|
||||
</div>
|
||||
{{#if ticketPdfUrl}}
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{ticketPdfUrl}}" class="btn" style="background-color: #1a1a1a; color: #f4d03f;">📄 Download Your Ticket (PDF)</a>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<div class="note">
|
||||
<strong>💡 Important:</strong> Please arrive 10-15 minutes early for check-in. Bring your ticket ID or show this email.
|
||||
<strong>💡 Important:</strong> Please arrive 10-15 minutes early for check-in. Show the PDF ticket or this email at the entrance.
|
||||
</div>
|
||||
|
||||
<p>See you at Spanglish!</p>
|
||||
@@ -263,18 +263,17 @@ export const defaultTemplates: DefaultTemplate[] = [
|
||||
|
||||
<div class="ticket-box">
|
||||
<p>Tu ID de Ticket</p>
|
||||
<p class="ticket-id">{{ticketId}}</p>
|
||||
<p class="ticket-id">{{qrCode}}</p>
|
||||
</div>
|
||||
|
||||
{{#if qrCode}}
|
||||
<div class="qr-code">
|
||||
<p><strong>Muestra este código QR en el check-in:</strong></p>
|
||||
<img src="{{qrCode}}" alt="Código QR de Check-in" />
|
||||
</div>
|
||||
{{#if ticketPdfUrl}}
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{ticketPdfUrl}}" class="btn" style="background-color: #1a1a1a; color: #f4d03f;">📄 Descargar Tu Ticket (PDF)</a>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<div class="note">
|
||||
<strong>💡 Importante:</strong> Por favor llega 10-15 minutos antes para el check-in. Trae tu ID de ticket o muestra este email.
|
||||
<strong>💡 Importante:</strong> Por favor llega 10-15 minutos antes para el check-in. Muestra el PDF del ticket o este email en la entrada.
|
||||
</div>
|
||||
|
||||
<p>¡Nos vemos en Spanglish!</p>
|
||||
|
||||
246
backend/src/lib/pdf.ts
Normal file
246
backend/src/lib/pdf.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
// PDF Ticket Generation Service
|
||||
import PDFDocument from 'pdfkit';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
interface TicketData {
|
||||
id: string;
|
||||
qrCode: string;
|
||||
attendeeName: string;
|
||||
attendeeEmail?: string;
|
||||
event: {
|
||||
title: string;
|
||||
startDatetime: string;
|
||||
endDatetime?: string;
|
||||
location: string;
|
||||
locationUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code as a data URL
|
||||
*/
|
||||
async function generateQRCode(data: string): Promise<Buffer> {
|
||||
return QRCode.toBuffer(data, {
|
||||
type: 'png',
|
||||
width: 200,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for display
|
||||
*/
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PDF ticket for a single ticket
|
||||
*/
|
||||
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
margin: 50,
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
||||
|
||||
// Generate QR code with ticket URL
|
||||
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
|
||||
const qrBuffer = await generateQRCode(qrUrl);
|
||||
|
||||
// ==================== Header ====================
|
||||
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
|
||||
|
||||
// Divider line
|
||||
doc.moveDown(1);
|
||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||
doc.moveDown(1);
|
||||
|
||||
// ==================== Event Info ====================
|
||||
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
// Date and time
|
||||
doc.fontSize(14).fillColor('#333');
|
||||
doc.text(formatDate(ticket.event.startDatetime), { align: 'center' });
|
||||
|
||||
const startTime = formatTime(ticket.event.startDatetime);
|
||||
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null;
|
||||
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
|
||||
doc.text(timeRange, { align: 'center' });
|
||||
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
|
||||
|
||||
// ==================== QR Code ====================
|
||||
doc.moveDown(2);
|
||||
|
||||
// Center the QR code
|
||||
const qrSize = 180;
|
||||
const pageWidth = 595; // A4 width in points
|
||||
const qrX = (pageWidth - qrSize) / 2;
|
||||
|
||||
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
|
||||
doc.y += qrSize + 10;
|
||||
|
||||
// ==================== Attendee Info ====================
|
||||
doc.moveDown(1);
|
||||
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
|
||||
|
||||
if (ticket.attendeeEmail) {
|
||||
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
|
||||
}
|
||||
|
||||
// ==================== Ticket ID ====================
|
||||
doc.moveDown(1);
|
||||
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
|
||||
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
|
||||
|
||||
// ==================== Footer ====================
|
||||
doc.moveDown(2);
|
||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||
doc.moveDown(0.5);
|
||||
|
||||
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
|
||||
|
||||
doc.end();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a combined PDF with multiple tickets
|
||||
*/
|
||||
export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
margin: 50,
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
||||
|
||||
for (let i = 0; i < tickets.length; i++) {
|
||||
const ticket = tickets[i];
|
||||
|
||||
if (i > 0) {
|
||||
doc.addPage();
|
||||
}
|
||||
|
||||
// Generate QR code
|
||||
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
|
||||
const qrBuffer = await generateQRCode(qrUrl);
|
||||
|
||||
// ==================== Header ====================
|
||||
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
|
||||
|
||||
// Divider line
|
||||
doc.moveDown(1);
|
||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||
doc.moveDown(1);
|
||||
|
||||
// ==================== Event Info ====================
|
||||
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
doc.fontSize(14).fillColor('#333');
|
||||
doc.text(formatDate(ticket.event.startDatetime), { align: 'center' });
|
||||
|
||||
const startTime = formatTime(ticket.event.startDatetime);
|
||||
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime) : null;
|
||||
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
|
||||
doc.text(timeRange, { align: 'center' });
|
||||
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
|
||||
|
||||
// ==================== QR Code ====================
|
||||
doc.moveDown(2);
|
||||
|
||||
const qrSize = 180;
|
||||
const pageWidth = 595;
|
||||
const qrX = (pageWidth - qrSize) / 2;
|
||||
|
||||
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
|
||||
doc.y += qrSize + 10;
|
||||
|
||||
// ==================== Attendee Info ====================
|
||||
doc.moveDown(1);
|
||||
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
|
||||
|
||||
if (ticket.attendeeEmail) {
|
||||
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
|
||||
}
|
||||
|
||||
// ==================== Ticket ID ====================
|
||||
doc.moveDown(1);
|
||||
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
|
||||
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
|
||||
|
||||
// Ticket number for multi-ticket bookings
|
||||
if (tickets.length > 1) {
|
||||
doc.text(`Ticket ${i + 1} of ${tickets.length}`, { align: 'center' });
|
||||
}
|
||||
|
||||
// ==================== Footer ====================
|
||||
doc.moveDown(2);
|
||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||
doc.moveDown(0.5);
|
||||
|
||||
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
|
||||
}
|
||||
|
||||
doc.end();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
generateTicketPDF,
|
||||
generateCombinedTicketsPDF,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
144
backend/src/routes/site-settings.ts
Normal file
144
backend/src/routes/site-settings.ts
Normal 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;
|
||||
@@ -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