import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, tickets, events, users, payments } from '../db/index.js'; import { eq, and, sql } from 'drizzle-orm'; 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'; const ticketsRouter = new Hono(); const createTicketSchema = z.object({ eventId: z.string(), firstName: z.string().min(2), lastName: z.string().min(2), email: z.string().email(), phone: z.string().min(6, 'Phone number is required'), preferredLanguage: z.enum(['en', 'es']).optional(), paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'), ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').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) ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { const data = c.req.valid('json'); // Get event const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get(); 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 const ticketCount = await (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where( and( eq((tickets as any).eventId, data.eventId), eq((tickets as any).status, 'confirmed') ) ) .get(); if ((ticketCount?.count || 0) >= event.capacity) { return c.json({ error: 'Event is sold out' }, 400); } // Find or create user let user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get(); const now = getNow(); const fullName = `${data.firstName} ${data.lastName}`.trim(); 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 const existingTicket = await (db as any) .select() .from(tickets) .where( and( eq((tickets as any).userId, user.id), eq((tickets as any).eventId, data.eventId) ) ) .get(); if (existingTicket && existingTicket.status !== 'cancelled') { return c.json({ error: 'You have already booked this event' }, 400); } // Create ticket const ticketId = generateId(); const qrCode = generateTicketCode(); // Cash payments start as pending, card/lightning start as pending until payment confirmed const ticketStatus = 'pending'; const newTicket = { id: ticketId, userId: user.id, eventId: data.eventId, attendeeFirstName: data.firstName, attendeeLastName: data.lastName, attendeeEmail: data.email, attendeePhone: data.phone, attendeeRuc: data.ruc || null, preferredLanguage: data.preferredLanguage || null, status: ticketStatus, qrCode, checkinAt: null, createdAt: now, }; await (db as any).insert(tickets).values(newTicket); // Create payment record 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); // If Lightning payment, create LNbits invoice let lnbitsInvoice = null; if (data.paymentMethod === 'lightning' && event.price > 0) { if (!isLNbitsConfigured()) { // Delete the ticket and payment we just created await (db as any).delete(payments).where(eq((payments as any).id, paymentId)); await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId)); 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 lnbitsInvoice = await createInvoice({ amount: event.price, unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc. memo: `Spanglish: ${event.title} - ${fullName}`, webhookUrl: `${apiUrl}/api/lnbits/webhook`, expiry: 900, // 15 minutes expiry for faster UX extra: { ticketId, eventId: event.id, eventTitle: event.title, attendeeName: fullName, attendeeEmail: data.email, }, }); // Update payment with LNbits payment hash reference await (db as any) .update(payments) .set({ reference: lnbitsInvoice.paymentHash }) .where(eq((payments as any).id, paymentId)); (newPayment as any).reference = lnbitsInvoice.paymentHash; } catch (error: any) { console.error('Failed to create Lightning invoice:', error); // Delete the ticket and payment we just created since Lightning payment failed await (db as any).delete(payments).where(eq((payments as any).id, paymentId)); await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId)); return c.json({ error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}` }, 500); } } return c.json({ ticket: { ...newTicket, event: { title: event.title, startDatetime: event.startDatetime, location: event.location, }, }, payment: newPayment, 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: 'Booking created successfully', }, 201); }); // Get ticket by ID ticketsRouter.get('/:id', async (c) => { const id = c.req.param('id'); 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); } // Get associated event const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get(); // Get payment const payment = await (db as any).select().from(payments).where(eq((payments as any).ticketId, id)).get(); 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 (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); 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 (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); return c.json({ ticket: updated }); }); // Check-in ticket ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => { const id = c.req.param('id'); 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); } 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); } await (db as any) .update(tickets) .set({ status: 'checked_in', checkinAt: getNow() }) .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' }); }); // Mark payment as received (for cash payments - admin only) 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 (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); 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(); // Update ticket status await (db as any) .update(tickets) .set({ status: 'confirmed' }) .where(eq((tickets as any).id, 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, id)); // Get payment for sending receipt const payment = await (db as any) .select() .from(payments) .where(eq((payments as any).ticketId, id)) .get(); // 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 (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); return c.json({ ticket: updated, message: '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 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); } // Get the payment const payment = await (db as any) .select() .from(payments) .where(eq((payments as any).ticketId, id)) .get(); 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); } // 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, updatedAt: now, }) .where(eq((payments as any).id, payment.id)); // Get updated payment const updatedPayment = await (db as any) .select() .from(payments) .where(eq((payments as any).id, payment.id)) .get(); // 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 = 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 (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 (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); 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 (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); 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 (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); 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 (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get(); 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 (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get(); if (!event) { return c.json({ error: 'Event not found' }, 404); } // Check capacity const ticketCount = await (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')` ) ) .get(); if ((ticketCount?.count || 0) >= event.capacity) { return c.json({ error: 'Event is at capacity' }, 400); } 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 (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)).get(); 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 (db as any) .select() .from(tickets) .where( and( eq((tickets as any).userId, user.id), eq((tickets as any).eventId, data.eventId) ) ) .get(); 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); }); // 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 query.all(); return c.json({ tickets: result }); }); export default ticketsRouter;