import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js'; import { eq, and, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; import emailService from '../lib/email.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; const ticketsRouter = new Hono(); // Attendee info schema for multi-ticket bookings const attendeeSchema = z.object({ firstName: z.string().min(2), lastName: z.string().min(2).optional().or(z.literal('')), }); const createTicketSchema = z.object({ eventId: z.string(), firstName: z.string().min(2), lastName: z.string().min(2).optional().or(z.literal('')), email: z.string().email(), phone: z.string().min(6).optional().or(z.literal('')), preferredLanguage: z.enum(['en', 'es']).optional(), paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'), ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')), // Optional: array of attendees for multi-ticket booking attendees: z.array(attendeeSchema).optional(), }); const updateTicketSchema = z.object({ status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(), adminNote: z.string().optional(), }); const updateNoteSchema = z.object({ note: z.string().max(1000), }); const adminCreateTicketSchema = z.object({ eventId: z.string(), firstName: z.string().min(2), lastName: z.string().optional().or(z.literal('')), email: z.string().email().optional().or(z.literal('')), phone: z.string().optional().or(z.literal('')), preferredLanguage: z.enum(['en', 'es']).optional(), autoCheckin: z.boolean().optional().default(false), adminNote: z.string().max(1000).optional(), }); // Book a ticket (public) - supports single or multi-ticket bookings ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { const data = c.req.valid('json'); // Determine attendees list (use attendees array if provided, otherwise single attendee from main fields) const attendeesList = data.attendees && data.attendees.length > 0 ? data.attendees : [{ firstName: data.firstName, lastName: data.lastName }]; const ticketCount = attendeesList.length; // Get event const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, data.eventId)) ); if (!event) { return c.json({ error: 'Event not found' }, 404); } if (event.status !== 'published') { return c.json({ error: 'Event is not available for booking' }, 400); } // Check capacity - count confirmed AND checked_in tickets // (checked_in were previously confirmed, check-in doesn't affect capacity) const existingTicketCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where( and( eq((tickets as any).eventId, data.eventId), sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` ) ) ); const confirmedCount = existingTicketCount?.count || 0; const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount); if (isEventSoldOut(event.capacity, confirmedCount)) { return c.json({ error: 'Event is sold out' }, 400); } if (ticketCount > availableSeats) { return c.json({ error: `Not enough seats available. Only ${availableSeats} spot(s) remaining.`, }, 400); } // Find or create user let user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, data.email)) ); const now = getNow(); const fullName = data.lastName && data.lastName.trim() ? `${data.firstName} ${data.lastName}`.trim() : data.firstName; if (!user) { const userId = generateId(); user = { id: userId, email: data.email, password: '', // No password for guest bookings name: fullName, phone: data.phone || null, role: 'user', languagePreference: null, createdAt: now, updatedAt: now, }; await (db as any).insert(users).values(user); } // Check for duplicate booking (unless allowDuplicateBookings is enabled) const globalOptions = await dbGet( (db as any) .select() .from(paymentOptions) ); const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false; if (!allowDuplicateBookings) { const existingTicket = await dbGet( (db as any) .select() .from(tickets) .where( and( eq((tickets as any).userId, user.id), eq((tickets as any).eventId, data.eventId) ) ) ); if (existingTicket && existingTicket.status !== 'cancelled') { return c.json({ error: 'You have already booked this event' }, 400); } } // Generate booking ID to group multiple tickets const bookingId = generateId(); // Create tickets for each attendee const createdTickets: any[] = []; const createdPayments: any[] = []; for (let i = 0; i < attendeesList.length; i++) { const attendee = attendeesList[i]; const ticketId = generateId(); const qrCode = generateTicketCode(); const newTicket = { id: ticketId, bookingId: ticketCount > 1 ? bookingId : null, // Only set bookingId for multi-ticket bookings userId: user.id, eventId: data.eventId, attendeeFirstName: attendee.firstName, attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null, attendeeEmail: data.email, // Buyer's email for all tickets attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, attendeeRuc: data.ruc || null, preferredLanguage: data.preferredLanguage || null, status: 'pending', qrCode, checkinAt: null, createdAt: now, }; await (db as any).insert(tickets).values(newTicket); createdTickets.push(newTicket); // Create payment record for each ticket const paymentId = generateId(); const newPayment = { id: paymentId, ticketId, provider: data.paymentMethod, amount: event.price, currency: event.currency, status: 'pending', reference: null, createdAt: now, updatedAt: now, }; await (db as any).insert(payments).values(newPayment); createdPayments.push(newPayment); } const primaryTicket = createdTickets[0]; const primaryPayment = createdPayments[0]; // Send payment instructions email for manual payment methods (TPago, Bank Transfer) if (['bank_transfer', 'tpago'].includes(data.paymentMethod)) { // Send asynchronously - don't block the response emailService.sendPaymentInstructions(primaryTicket.id).then(result => { if (result.success) { console.log(`[Email] Payment instructions email sent successfully for ticket ${primaryTicket.id}`); } else { console.error(`[Email] Failed to send payment instructions email for ticket ${primaryTicket.id}:`, result.error); } }).catch(err => { console.error('[Email] Exception sending payment instructions email:', err); }); } // If Lightning payment, create LNbits invoice let lnbitsInvoice = null; const totalPrice = event.price * ticketCount; if (data.paymentMethod === 'lightning' && totalPrice > 0) { if (!isLNbitsConfigured()) { // Delete the tickets and payments we just created for (const payment of createdPayments) { await (db as any).delete(payments).where(eq((payments as any).id, payment.id)); } for (const ticket of createdTickets) { await (db as any).delete(tickets).where(eq((tickets as any).id, ticket.id)); } return c.json({ error: 'Bitcoin Lightning payments are not available at this time' }, 400); } try { const apiUrl = process.env.API_URL || 'http://localhost:3001'; // Pass the fiat currency directly to LNbits - it handles conversion automatically // For multi-ticket, use total price lnbitsInvoice = await createInvoice({ amount: totalPrice, unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc. memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`, webhookUrl: `${apiUrl}/api/lnbits/webhook`, expiry: 900, // 15 minutes expiry for faster UX extra: { ticketId: primaryTicket.id, bookingId: ticketCount > 1 ? bookingId : null, ticketIds: createdTickets.map(t => t.id), eventId: event.id, eventTitle: event.title, attendeeName: fullName, attendeeEmail: data.email, ticketCount, }, }); // Update primary payment with LNbits payment hash reference await (db as any) .update(payments) .set({ reference: lnbitsInvoice.paymentHash }) .where(eq((payments as any).id, primaryPayment.id)); (primaryPayment as any).reference = lnbitsInvoice.paymentHash; } catch (error: any) { console.error('Failed to create Lightning invoice:', error); // Delete the tickets and payments we just created since Lightning payment failed for (const payment of createdPayments) { await (db as any).delete(payments).where(eq((payments as any).id, payment.id)); } for (const ticket of createdTickets) { await (db as any).delete(tickets).where(eq((tickets as any).id, ticket.id)); } return c.json({ error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}` }, 500); } } // Response format depends on single vs multi-ticket const eventInfo = { title: event.title, startDatetime: event.startDatetime, location: event.location, }; return c.json({ // For backward compatibility, include primary ticket as 'ticket' ticket: { ...primaryTicket, event: eventInfo, }, // For multi-ticket bookings, include all tickets tickets: createdTickets.map(t => ({ ...t, event: eventInfo, })), bookingId: ticketCount > 1 ? bookingId : null, payment: primaryPayment, payments: createdPayments, lightningInvoice: lnbitsInvoice ? { paymentHash: lnbitsInvoice.paymentHash, paymentRequest: lnbitsInvoice.paymentRequest, amount: lnbitsInvoice.amount, // Amount in satoshis fiatAmount: lnbitsInvoice.fiatAmount, fiatCurrency: lnbitsInvoice.fiatCurrency, expiry: lnbitsInvoice.expiry, } : null, message: ticketCount > 1 ? `${ticketCount} tickets booked successfully` : 'Booking created successfully', }, 201); }); // Download combined PDF for multi-ticket booking // NOTE: This route MUST be defined before /:id/pdf to prevent the wildcard from matching "booking" ticketsRouter.get('/booking/:bookingId/pdf', async (c) => { const bookingId = c.req.param('bookingId'); const user: any = await getAuthUser(c); console.log(`[PDF] Generating combined PDF for booking: ${bookingId}`); // Get all tickets in this booking const bookingTickets = await dbAll( (db as any) .select() .from(tickets) .where(eq((tickets as any).bookingId, bookingId)) ); console.log(`[PDF] Found ${bookingTickets?.length || 0} tickets for booking ${bookingId}`); if (!bookingTickets || bookingTickets.length === 0) { return c.json({ error: 'Booking not found' }, 404); } const primaryTicket = bookingTickets[0] as any; // Check authorization - must be ticket owner or admin if (user) { const isAdmin = ['admin', 'organizer', 'staff'].includes(user.role); const isOwner = user.id === primaryTicket.userId; if (!isAdmin && !isOwner) { return c.json({ error: 'Unauthorized' }, 403); } } // Check that at least one ticket is confirmed const hasConfirmedTicket = bookingTickets.some((t: any) => ['confirmed', 'checked_in'].includes(t.status) ); if (!hasConfirmedTicket) { return c.json({ error: 'No confirmed tickets in this booking' }, 400); } // Get event const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, primaryTicket.eventId)) ); if (!event) { return c.json({ error: 'Event not found' }, 404); } try { // Filter to only confirmed/checked_in tickets const confirmedTickets = bookingTickets.filter((t: any) => ['confirmed', 'checked_in'].includes(t.status) ); console.log(`[PDF] Generating PDF with ${confirmedTickets.length} confirmed tickets`); // Get site timezone for proper date/time formatting const settings = await dbGet( (db as any).select().from(siteSettings).limit(1) ); const timezone = settings?.timezone || 'America/Asuncion'; const ticketsData = confirmedTickets.map((ticket: any) => ({ 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, }, timezone, })); const pdfBuffer = await generateCombinedTicketsPDF(ticketsData); // Set response headers for PDF download return new Response(new Uint8Array(pdfBuffer), { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="spanglish-booking-${bookingId}.pdf"`, }, }); } catch (error: any) { console.error('Combined PDF generation error:', error); return c.json({ error: 'Failed to generate PDF' }, 500); } }); // Download ticket as PDF (single ticket) ticketsRouter.get('/:id/pdf', async (c) => { const id = c.req.param('id'); const user: any = await getAuthUser(c); const ticket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); 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 dbGet( (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)) ); if (!event) { return c.json({ error: 'Event not found' }, 404); } try { // Get site timezone for proper date/time formatting const settings = await dbGet( (db as any).select().from(siteSettings).limit(1) ); const timezone = settings?.timezone || 'America/Asuncion'; 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, }, timezone, }); // 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'); const ticket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } // Get associated event const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)) ); // Get payment const payment = await dbGet( (db as any).select().from(payments).where(eq((payments as any).ticketId, id)) ); return c.json({ ticket: { ...ticket, event, payment, }, }); }); // Update ticket status (admin/organizer) ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateTicketSchema), async (c) => { const id = c.req.param('id'); const data = c.req.valid('json'); const ticket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } const updates: any = {}; if (data.status) { updates.status = data.status; if (data.status === 'checked_in') { updates.checkinAt = getNow(); } } if (Object.keys(updates).length > 0) { await (db as any).update(tickets).set(updates).where(eq((tickets as any).id, id)); } const updated = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); 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 dbGet( (db as any) .select() .from(tickets) .where(eq((tickets as any).qrCode, code)) ); // If not found by QR, try by ID if (!ticket) { ticket = await dbGet( (db as any) .select() .from(tickets) .where(eq((tickets as any).id, code)) ); } 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 dbGet( (db as any) .select() .from(events) .where(eq((events as any).id, ticket.eventId)) ); // 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 dbGet( (db as any) .select() .from(users) .where(eq((users as any).id, ticket.checkedInByAdminId)) ); 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 dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } if (ticket.status === 'checked_in') { return c.json({ error: 'Ticket already checked in' }, 400); } if (ticket.status !== 'confirmed') { 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: now, checkedInByAdminId: adminUser?.id || null, }) .where(eq((tickets as any).id, id)); const updated = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); // Get event for response const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)) ); 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) // Supports multi-ticket bookings - confirms all tickets in the booking ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']), async (c) => { const id = c.req.param('id'); const user = (c as any).get('user'); const ticket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } if (ticket.status === 'confirmed') { return c.json({ error: 'Ticket already confirmed' }, 400); } if (ticket.status === 'cancelled') { return c.json({ error: 'Cannot confirm cancelled ticket' }, 400); } const now = getNow(); // Get all tickets in this booking (if multi-ticket) let ticketsToConfirm: any[] = [ticket]; if (ticket.bookingId) { // This is a multi-ticket booking - get all tickets with same bookingId ticketsToConfirm = await dbAll( (db as any) .select() .from(tickets) .where(eq((tickets as any).bookingId, ticket.bookingId)) ); } // Confirm all tickets in the booking for (const t of ticketsToConfirm) { // Update ticket status await (db as any) .update(tickets) .set({ status: 'confirmed' }) .where(eq((tickets as any).id, t.id)); // Update payment status await (db as any) .update(payments) .set({ status: 'paid', paidAt: now, paidByAdminId: user.id, updatedAt: now, }) .where(eq((payments as any).ticketId, t.id)); } // Get payment for sending receipt const payment = await dbGet( (db as any) .select() .from(payments) .where(eq((payments as any).ticketId, id)) ); // Send confirmation emails asynchronously (don't block the response) Promise.all([ emailService.sendBookingConfirmation(id), payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(), ]).catch(err => { console.error('[Email] Failed to send confirmation emails:', err); }); const updated = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); return c.json({ ticket: updated, message: ticketsToConfirm.length > 1 ? `${ticketsToConfirm.length} tickets marked as paid` : 'Payment marked as received' }); }); // User marks payment as sent (for manual payment methods: bank_transfer, tpago) // This sets status to "pending_approval" and notifies admin ticketsRouter.post('/:id/mark-payment-sent', async (c) => { const id = c.req.param('id'); const body = await c.req.json().catch(() => ({})); const { payerName } = body; const ticket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } // Get the payment const payment = await dbGet( (db as any) .select() .from(payments) .where(eq((payments as any).ticketId, id)) ); if (!payment) { return c.json({ error: 'Payment not found' }, 404); } // Only allow for manual payment methods if (!['bank_transfer', 'tpago'].includes(payment.provider)) { return c.json({ error: 'This action is only available for bank transfer or TPago payments' }, 400); } // Handle idempotency - if already marked as sent or paid, return success with current state if (payment.status === 'pending_approval') { return c.json({ payment, message: 'Payment was already marked as sent. Waiting for admin approval.', alreadyProcessed: true, }); } if (payment.status === 'paid') { return c.json({ payment, message: 'Payment has already been confirmed.', alreadyProcessed: true, }); } // Only allow if currently pending if (payment.status !== 'pending') { return c.json({ error: 'Payment has already been processed' }, 400); } const now = getNow(); // Update payment status to pending_approval await (db as any) .update(payments) .set({ status: 'pending_approval', userMarkedPaidAt: now, payerName: payerName?.trim() || null, updatedAt: now, }) .where(eq((payments as any).id, payment.id)); // Get updated payment const updatedPayment = await dbGet( (db as any) .select() .from(payments) .where(eq((payments as any).id, payment.id)) ); // TODO: Send notification to admin about pending payment approval return c.json({ payment: updatedPayment, message: 'Payment marked as sent. Waiting for admin approval.' }); }); // Cancel ticket ticketsRouter.post('/:id/cancel', async (c) => { const id = c.req.param('id'); const user: any = await getAuthUser(c); const ticket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } // Check authorization (admin or ticket owner) if (!user || (user.role !== 'admin' && user.id !== ticket.userId)) { return c.json({ error: 'Unauthorized' }, 403); } if (ticket.status === 'cancelled') { return c.json({ error: 'Ticket already cancelled' }, 400); } await (db as any).update(tickets).set({ status: 'cancelled' }).where(eq((tickets as any).id, id)); return c.json({ message: 'Ticket cancelled successfully' }); }); // Remove check-in (reset to confirmed) ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => { const id = c.req.param('id'); const ticket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } if (ticket.status !== 'checked_in') { return c.json({ error: 'Ticket is not checked in' }, 400); } await (db as any) .update(tickets) .set({ status: 'confirmed', checkinAt: null }) .where(eq((tickets as any).id, id)); const updated = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); return c.json({ ticket: updated, message: 'Check-in removed successfully' }); }); // Update admin note ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateNoteSchema), async (c) => { const id = c.req.param('id'); const { note } = c.req.valid('json'); const ticket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); if (!ticket) { return c.json({ error: 'Ticket not found' }, 404); } await (db as any) .update(tickets) .set({ adminNote: note || null }) .where(eq((tickets as any).id, id)); const updated = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, id)) ); return c.json({ ticket: updated, message: 'Note updated successfully' }); }); // Admin create ticket (at the door) ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', adminCreateTicketSchema), async (c) => { const data = c.req.valid('json'); // Get event const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, data.eventId)) ); if (!event) { return c.json({ error: 'Event not found' }, 404); } // Admin create at door: bypass capacity check (allow over-capacity for walk-ins) const now = getNow(); // For door sales, email might be empty - use a generated placeholder const attendeeEmail = data.email && data.email.trim() ? data.email.trim() : `door-${generateId()}@doorentry.local`; // Find or create user let user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) ); const adminFullName = data.lastName && data.lastName.trim() ? `${data.firstName} ${data.lastName}`.trim() : data.firstName; if (!user) { const userId = generateId(); user = { id: userId, email: attendeeEmail, password: '', name: adminFullName, phone: data.phone || null, role: 'user', languagePreference: null, createdAt: now, updatedAt: now, }; await (db as any).insert(users).values(user); } // Check for existing active ticket for this user and event (only if real email provided) if (data.email && data.email.trim() && !data.email.includes('@doorentry.local')) { const existingTicket = await dbGet( (db as any) .select() .from(tickets) .where( and( eq((tickets as any).userId, user.id), eq((tickets as any).eventId, data.eventId) ) ) ); if (existingTicket && existingTicket.status !== 'cancelled') { return c.json({ error: 'This person already has a ticket for this event' }, 400); } } // Create ticket const ticketId = generateId(); const qrCode = generateTicketCode(); // For door sales, mark as confirmed (or checked_in if auto-checkin) const ticketStatus = data.autoCheckin ? 'checked_in' : 'confirmed'; const newTicket = { id: ticketId, userId: user.id, eventId: data.eventId, attendeeFirstName: data.firstName, attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, preferredLanguage: data.preferredLanguage || null, status: ticketStatus, qrCode, checkinAt: data.autoCheckin ? now : null, adminNote: data.adminNote || null, createdAt: now, }; await (db as any).insert(tickets).values(newTicket); // Create payment record (marked as paid for door sales) const paymentId = generateId(); const adminUser = (c as any).get('user'); const newPayment = { id: paymentId, ticketId, provider: 'cash', amount: event.price, currency: event.currency, status: 'paid', reference: 'Door sale', paidAt: now, paidByAdminId: adminUser?.id || null, createdAt: now, updatedAt: now, }; await (db as any).insert(payments).values(newPayment); return c.json({ ticket: { ...newTicket, event: { title: event.title, startDatetime: event.startDatetime, location: event.location, }, }, payment: newPayment, message: data.autoCheckin ? 'Attendee added and checked in successfully' : 'Attendee added successfully', }, 201); }); // Admin create manual ticket (sends confirmation email + ticket to attendee) ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ eventId: z.string(), firstName: z.string().min(2), lastName: z.string().optional().or(z.literal('')), email: z.string().email('Valid email is required for manual tickets'), phone: z.string().optional().or(z.literal('')), preferredLanguage: z.enum(['en', 'es']).optional(), adminNote: z.string().max(1000).optional(), })), async (c) => { const data = c.req.valid('json'); // Get event const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, data.eventId)) ); if (!event) { return c.json({ error: 'Event not found' }, 404); } // Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets) const now = getNow(); const attendeeEmail = data.email.trim(); // Find or create user let user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) ); const fullName = data.lastName && data.lastName.trim() ? `${data.firstName} ${data.lastName}`.trim() : data.firstName; if (!user) { const userId = generateId(); user = { id: userId, email: attendeeEmail, password: '', name: fullName, phone: data.phone || null, role: 'user', languagePreference: null, createdAt: now, updatedAt: now, }; await (db as any).insert(users).values(user); } // Check for existing active ticket for this user and event const existingTicket = await dbGet( (db as any) .select() .from(tickets) .where( and( eq((tickets as any).userId, user.id), eq((tickets as any).eventId, data.eventId) ) ) ); if (existingTicket && existingTicket.status !== 'cancelled') { return c.json({ error: 'This person already has a ticket for this event' }, 400); } // Create ticket as confirmed const ticketId = generateId(); const qrCode = generateTicketCode(); const newTicket = { id: ticketId, userId: user.id, eventId: data.eventId, attendeeFirstName: data.firstName, attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, attendeeEmail: attendeeEmail, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, preferredLanguage: data.preferredLanguage || null, status: 'confirmed', qrCode, checkinAt: null, adminNote: data.adminNote || null, createdAt: now, }; await (db as any).insert(tickets).values(newTicket); // Create payment record (marked as paid - manual entry) const paymentId = generateId(); const adminUser = (c as any).get('user'); const newPayment = { id: paymentId, ticketId, provider: 'cash', amount: event.price, currency: event.currency, status: 'paid', reference: 'Manual ticket', paidAt: now, paidByAdminId: adminUser?.id || null, createdAt: now, updatedAt: now, }; await (db as any).insert(payments).values(newPayment); // Send booking confirmation email + ticket (asynchronously) emailService.sendBookingConfirmation(ticketId).then(result => { if (result.success) { console.log(`[Email] Booking confirmation sent for manual ticket ${ticketId}`); } else { console.error(`[Email] Failed to send booking confirmation for manual ticket ${ticketId}:`, result.error); } }).catch(err => { console.error('[Email] Exception sending booking confirmation for manual ticket:', err); }); return c.json({ ticket: { ...newTicket, event: { title: event.title, startDatetime: event.startDatetime, location: event.location, }, }, payment: newPayment, message: 'Manual ticket created and confirmation email sent', }, 201); }); // Get all tickets (admin) ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { const eventId = c.req.query('eventId'); const status = c.req.query('status'); let query = (db as any).select().from(tickets); const conditions = []; if (eventId) { conditions.push(eq((tickets as any).eventId, eventId)); } if (status) { conditions.push(eq((tickets as any).status, status)); } if (conditions.length > 0) { query = query.where(and(...conditions)); } const result = await dbAll(query); return c.json({ tickets: result }); }); export default ticketsRouter;