import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js'; import { eq, and, or, sql, inArray } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; import emailService from '../lib/email.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js'; import { seatHolderCountQuery } from '../lib/capacity.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('')), }); // Maximum tickets a single buyer can book at once (enforced server-side) const MAX_TICKETS_PER_BOOKING = 5; 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(), // 'bancard' intentionally excluded: no checkout integration exists for it paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'), // Base + optional "-" + check digit; digits-only kept for older clients, normalized to dashed form on save ruc: z.string().regex(/^(\d{6,10}|\d{5,8}-\d)$/, 'Invalid RUC format').optional().or(z.literal('')), // Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING) attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(), }); // Canonical stored RUC form is "base-checkdigit" (e.g. 1234567-9); older clients send digits only function normalizeRuc(ruc: string | undefined): string | null { if (!ruc) return null; if (ruc.includes('-')) return ruc; return `${ruc.slice(0, -1)}-${ruc.slice(-1)}`; } // Maps a payment provider to the merged payment-option flag that enables it function isPaymentMethodEnabled(method: string, merged: Record): boolean { const truthy = (v: any) => v === true || v === 1; switch (method) { case 'tpago': return truthy(merged.tpagoEnabled); case 'bank_transfer': return truthy(merged.bankTransferEnabled); case 'lightning': return truthy(merged.lightningEnabled); case 'cash': return truthy(merged.cashEnabled); default: return false; } } const updateTicketSchema = z.object({ status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold']).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'); const rucNumber = normalizeRuc(data.ruc); // 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; // Enforce the per-booking ticket cap server-side (UI also caps, but the API is authoritative) if (ticketCount < 1 || ticketCount > MAX_TICKETS_PER_BOOKING) { return c.json({ error: `You can book between 1 and ${MAX_TICKETS_PER_BOOKING} tickets per order.` }, 400); } // 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 (!['published', 'unlisted'].includes(event.status)) { return c.json({ error: 'Event is not available for booking' }, 400); } // Validate the requested payment method is actually enabled for this event // (merge global options with any event-level overrides; override wins when not null) const globalPaymentOptions = await dbGet( (db as any).select().from(paymentOptions) ); const eventOverrides = await dbGet( (db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, data.eventId)) ); const mergedPaymentOptions: Record = { tpagoEnabled: eventOverrides?.tpagoEnabled ?? globalPaymentOptions?.tpagoEnabled ?? false, bankTransferEnabled: eventOverrides?.bankTransferEnabled ?? globalPaymentOptions?.bankTransferEnabled ?? false, lightningEnabled: eventOverrides?.lightningEnabled ?? globalPaymentOptions?.lightningEnabled ?? true, cashEnabled: eventOverrides?.cashEnabled ?? globalPaymentOptions?.cashEnabled ?? true, }; if (!isPaymentMethodEnabled(data.paymentMethod, mergedPaymentOptions)) { return c.json({ error: 'Selected payment method is not available for this event' }, 400); } // Check capacity against held seats (paid/checked-in tickets plus claimed // manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat. const existingTicketCount = await dbGet( seatHolderCountQuery(db, data.eventId) ); 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. The account row is keyed on the normalized address so // it stays reachable from Better Auth (which lowercases every lookup) — // tickets.attendeeEmail below keeps the address exactly as the buyer typed it. const accountEmail = normalizeEmail(data.email); let user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, accountEmail)) ); 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: accountEmail, password: null, // No password for guest bookings; set on claim (Better Auth credential account) name: fullName, phone: data.phone || null, role: 'user', languagePreference: null, rucNumber, isClaimed: toDbBool(false), accountStatus: 'unclaimed', emailVerified: false, createdAt: now, updatedAt: now, }; await (db as any).insert(users).values(user); } else if (rucNumber) { // Keep the user's saved RUC up to date for future bookings, but never blank // out an existing value if this booking didn't include one. await (db as any) .update(users) .set({ rucNumber, updatedAt: now }) .where(eq((users as any).id, user.id)); user.rucNumber = rucNumber; } // Check for duplicate booking (unless allowDuplicateBookings is enabled) const allowDuplicateBookings = globalPaymentOptions?.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(); // Atomically re-check capacity and insert tickets/payments inside a transaction // so concurrent bookings cannot oversell the same seats (TOCTOU race). class BookingCapacityError extends Error { constructor(public code: 'SOLD_OUT' | 'NOT_ENOUGH', public available?: number) { super(code); } } let createdTickets: any[] = []; let createdPayments: any[] = []; try { if (isSqlite()) { (db as any).transaction((tx: any) => { const countRow = seatHolderCountQuery(tx, data.eventId).get(); const reserved = Number(countRow?.count || 0); if (isEventSoldOut(event.capacity, reserved)) { throw new BookingCapacityError('SOLD_OUT'); } const seatsLeft = calculateAvailableSeats(event.capacity, reserved); if (ticketCount > seatsLeft) { throw new BookingCapacityError('NOT_ENOUGH', seatsLeft); } 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, userId: user.id, eventId: data.eventId, attendeeFirstName: attendee.firstName, attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null, attendeeEmail: data.email, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, attendeeRuc: rucNumber, preferredLanguage: data.preferredLanguage || null, status: 'pending', qrCode, checkinAt: null, createdAt: now, }; tx.insert(tickets).values(newTicket).run(); createdTickets.push(newTicket); 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, }; tx.insert(payments).values(newPayment).run(); createdPayments.push(newPayment); } }); } else { await (db as any).transaction(async (tx: any) => { const countRow = await dbGet(seatHolderCountQuery(tx, data.eventId)); const reserved = Number(countRow?.count || 0); if (isEventSoldOut(event.capacity, reserved)) { throw new BookingCapacityError('SOLD_OUT'); } const seatsLeft = calculateAvailableSeats(event.capacity, reserved); if (ticketCount > seatsLeft) { throw new BookingCapacityError('NOT_ENOUGH', seatsLeft); } 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, userId: user.id, eventId: data.eventId, attendeeFirstName: attendee.firstName, attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null, attendeeEmail: data.email, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, attendeeRuc: rucNumber, preferredLanguage: data.preferredLanguage || null, status: 'pending', qrCode, checkinAt: null, createdAt: now, }; await tx.insert(tickets).values(newTicket); createdTickets.push(newTicket); 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 tx.insert(payments).values(newPayment); createdPayments.push(newPayment); } }); } } catch (err: any) { if (err instanceof BookingCapacityError) { if (err.code === 'SOLD_OUT') { return c.json({ error: 'Event is sold out' }, 400); } return c.json({ error: `Not enough seats available. Only ${err.available} spot(s) remaining.`, }, 400); } throw err; } 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 (skip for free events — confirm immediately) let lnbitsInvoice = null; const totalPrice = event.price * ticketCount; // Free events: no payment step required — confirm tickets immediately if (totalPrice === 0) { for (const t of createdTickets) { await (db as any) .update(tickets) .set({ status: 'confirmed', paymentStatus: 'paid' }) .where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending'))); await (db as any) .update(payments) .set({ status: 'paid', paidAt: now, updatedAt: now }) .where(and(eq((payments as any).ticketId, t.id), eq((payments as any).status, 'pending'))); } emailService.sendBookingConfirmation(primaryTicket.id).catch(err => { console.error('[Email] Failed to send free-booking confirmation:', err); }); } else if (data.paymentMethod === 'lightning') { 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'; // Include the webhook secret (if configured) so the callback can be authenticated const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || ''; const webhookUrl = webhookSecret ? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}` : `${apiUrl}/api/lnbits/webhook`; // 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, expiry: LNBITS_INVOICE_EXPIRY_SECONDS, // 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, }, }); const lnbitsExpiresAt = toDbDate(new Date(Date.now() + LNBITS_INVOICE_EXPIRY_SECONDS * 1000)); // Update primary payment with the LNbits invoice - the BOLT11 string and // expiry are persisted so the "Pay now" page can redisplay this same // invoice later instead of erroring out on an unpaid Lightning booking. await (db as any) .update(payments) .set({ reference: lnbitsInvoice.paymentHash, lnbitsInvoice: lnbitsInvoice.paymentRequest, lnbitsExpiresAt, lnbitsAmountSats: lnbitsInvoice.amount, }) .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) => { const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en'; return { id: ticket.id, qrCode: ticket.qrCode, attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), attendeeEmail: ticket.attendeeEmail, event: { title: locale === 'es' && event.titleEs ? event.titleEs : event.title, startDatetime: event.startDatetime, endDatetime: event.endDatetime, location: event.location, locationUrl: event.locationUrl, }, timezone, locale, }; }); 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 locale = ticket.preferredLanguage === 'es' ? 'es' : 'en'; const pdfBuffer = await generateTicketPDF({ id: ticket.id, qrCode: ticket.qrCode, attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), attendeeEmail: ticket.attendeeEmail, event: { title: locale === 'es' && event.titleEs ? event.titleEs : event.title, startDatetime: event.startDatetime, endDatetime: event.endDatetime, location: event.location, locationUrl: event.locationUrl, }, timezone, locale, }); // 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 event check-in stats for scanner (lightweight endpoint for staff) ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => { const eventId = c.req.query('eventId'); if (!eventId) { return c.json({ error: 'eventId is required' }, 400); } // Get event info const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, eventId)) ); if (!event) { return c.json({ error: 'Event not found' }, 404); } // Count checked-in tickets const checkedInCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where( and( eq((tickets as any).eventId, eventId), eq((tickets as any).status, 'checked_in') ) ) ); // Count confirmed + checked_in (total active) const totalActiveCount = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(tickets) .where( and( eq((tickets as any).eventId, eventId), sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` ) ) ); return c.json({ eventId, capacity: event.capacity, checkedIn: checkedInCount?.count || 0, totalActive: totalActiveCount?.count || 0, }); }); // Live search tickets (GET - for scanner live search) ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => { const q = c.req.query('q')?.trim() || ''; const eventId = c.req.query('eventId'); if (q.length < 2) { return c.json({ tickets: [] }); } const searchTerm = `%${q.toLowerCase()}%`; // Search by name (ILIKE), email (ILIKE), ticket ID (exact or partial) const nameEmailConditions = [ sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`, sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`, sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`, sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`, // Ticket ID exact or partial match (cast UUID to text for LOWER) sql`LOWER(CAST(${(tickets as any).id} AS TEXT)) LIKE ${searchTerm}`, sql`LOWER(CAST(${(tickets as any).qrCode} AS TEXT)) LIKE ${searchTerm}`, ]; let whereClause: any = and( or(...nameEmailConditions), // Exclude cancelled tickets by default sql`${(tickets as any).status} != 'cancelled'` ); if (eventId) { whereClause = and(whereClause, eq((tickets as any).eventId, eventId)); } const matchingTickets = await dbAll( (db as any) .select() .from(tickets) .where(whereClause) .limit(20) ); // Enrich with event details const results = await Promise.all( matchingTickets.map(async (ticket: any) => { const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)) ); return { ticket_id: ticket.id, name: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), email: ticket.attendeeEmail, status: ticket.status, checked_in: ticket.status === 'checked_in', checkinAt: ticket.checkinAt, event_id: ticket.eventId, qrCode: ticket.qrCode, event: event ? { id: event.id, title: event.title, startDatetime: event.startDatetime, location: event.location, } : null, }; }) ); return c.json({ tickets: results }); }); // Get ticket by ID // Capability-based access: the unguessable ticket UUID acts as the access token for // guest bookings (no account required). For anonymous callers we withhold attendee PII // (email/phone/RUC); the full record is only returned to the owner or admin/staff. 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)) ); // Count how many tickets belong to this booking (for per-quantity payment links) let bookingTicketCount = 1; if (ticket.bookingId) { const bookingTickets = await dbAll( (db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId)) ); bookingTicketCount = bookingTickets.length || 1; } // Determine whether the requester is the owner or an admin/staff member const authUser: any = await getAuthUser(c); const isPrivileged = !!authUser && ( ['admin', 'organizer', 'staff'].includes(authUser.role) || authUser.id === ticket.userId ); const ticketPayload: any = { ...ticket, event, payment, bookingTicketCount }; if (!isPrivileged) { // Strip attendee PII for anonymous capability-based access delete ticketPayload.attendeeEmail; delete ticketPayload.attendeePhone; delete ticketPayload.attendeeRuc; } return c.json({ ticket: ticketPayload }); }); // 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 }); }); // Search tickets by name/email (for scanner manual search) ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => { const body = await c.req.json().catch(() => ({})); const { query, eventId } = body; if (!query || typeof query !== 'string' || query.trim().length < 2) { return c.json({ error: 'Search query must be at least 2 characters' }, 400); } const searchTerm = `%${query.trim().toLowerCase()}%`; const conditions = [ sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`, sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`, sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`, sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`, ]; let whereClause = or(...conditions); if (eventId) { whereClause = and(whereClause, eq((tickets as any).eventId, eventId)); } const matchingTickets = await dbAll( (db as any) .select() .from(tickets) .where(whereClause) .limit(20) ); // Enrich with event details const results = await Promise.all( matchingTickets.map(async (ticket: any) => { const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)) ); return { id: ticket.id, qrCode: ticket.qrCode, attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), attendeeEmail: ticket.attendeeEmail, attendeePhone: ticket.attendeePhone, status: ticket.status, checkinAt: ticket.checkinAt, event: event ? { id: event.id, title: event.title, startDatetime: event.startDatetime, location: event.location, } : null, }; }) ); return c.json({ tickets: results }); }); // 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, paymentStatus: ticket.paymentStatus, // Balance to collect at the door for unpaid tickets amountDue: ticket.paymentStatus === 'unpaid' && event ? event.price : 0, 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); } // Confirmed/checked-in tickets can still be marked paid when they carry an // unpaid balance (admin-added unpaid tickets collected at the door) if (['confirmed', 'checked_in'].includes(ticket.status) && ticket.paymentStatus !== 'unpaid') { 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)) ); } if (ticket.status === 'on_hold') { // The seat was released when this booking went on hold - re-check capacity // before confirming it directly. try { await reserveOnHoldBooking( ticket.eventId, ticketsToConfirm.map((t: any) => t.id), 'confirmed', 'paid', { paidByAdminId: user.id } ); } catch (err) { if (err instanceof HoldCapacityError) { return c.json({ error: 'This event is now full. Your spot was released after the payment deadline passed.', }, 400); } throw err; } } else { // Confirm all tickets in the booking (checked-in tickets keep their status) for (const t of ticketsToConfirm) { // Update ticket status await (db as any) .update(tickets) .set({ status: t.status === 'checked_in' ? 'checked_in' : 'confirmed', paymentStatus: 'paid' }) .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', rateLimitMiddleware({ max: 10, windowMs: 10 * 60 * 1000, prefix: '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, }); } // A booking that was auto-released after the hold threshold: recover it by // re-reserving the seat(s) and moving back into the admin approval queue. if (payment.status === 'on_hold') { let ticketsToRecover: any[] = [ticket]; if (ticket.bookingId) { ticketsToRecover = await dbAll( (db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId)) ); } try { await reserveOnHoldBooking( ticket.eventId, ticketsToRecover.map((t: any) => t.id), 'pending', 'pending_approval', { extraPaymentFields: { userMarkedPaidAt: getNow(), payerName: payerName?.trim() || null } } ); } catch (err) { if (err instanceof HoldCapacityError) { return c.json({ error: 'This event is now full. Your spot was released after the payment deadline passed.', }, 400); } throw err; } const recoveredPayment = await dbGet( (db as any).select().from(payments).where(eq((payments as any).id, payment.id)) ); return c.json({ payment: recoveredPayment, message: 'Payment marked as sent. Waiting for admin approval.', }); } // 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 for this ticket and any siblings // in a multi-ticket booking (mirrors the approve flow's bookingId fan-out). let ticketsToMark: any[] = [ticket]; if (ticket.bookingId) { ticketsToMark = await dbAll( (db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId)) ); } for (const t of ticketsToMark) { await (db as any) .update(payments) .set({ status: 'pending_approval', userMarkedPaidAt: now, payerName: payerName?.trim() || null, updatedAt: now, }) .where( and( eq((payments as any).ticketId, (t as any).id), eq((payments as any).status, 'pending') ) ); } // Get updated payment for the requested ticket 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 (see the note on `accountEmail` in the booking route) const accountEmail = normalizeEmail(attendeeEmail); let user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, accountEmail)) ); const adminFullName = data.lastName && data.lastName.trim() ? `${data.firstName} ${data.lastName}`.trim() : data.firstName; if (!user) { const userId = generateId(); user = { id: userId, email: accountEmail, password: null, name: adminFullName, phone: data.phone || null, role: 'user', languagePreference: null, isClaimed: toDbBool(false), accountStatus: 'unclaimed', emailVerified: false, 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, paymentStatus: 'paid', 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); }); // Unified admin add-attendee endpoint backing the single Add Ticket modal. // type drives payment handling: // paid — email required; paid cash payment; confirmation email + QR sent // door — paid in cash at the door; all fields optional; counts toward revenue; // confirmation email only when an email is provided // unpaid — QR issued with balance due (collect at door); pending tpago payment; // pay-link (Bancard/TPago) email sent when an email is provided // guest — free comp ticket, not counted in revenue; confirmation email only // when an email is provided ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ eventId: z.string(), type: z.enum(['paid', 'door', 'unpaid', 'guest']), // Door walk-ins can be logged with nothing filled in, so firstName is only // required for the other types firstName: z.string().optional().or(z.literal('')), 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(), checkinNow: z.boolean().optional().default(false), adminNote: z.string().max(1000).optional(), }).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), { message: 'Email is required for paid tickets', path: ['email'], }).refine((d) => d.type === 'door' || !!(d.firstName && d.firstName.trim()), { message: 'First name is required', path: ['firstName'], })), async (c) => { const data = c.req.valid('json'); 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-added tickets bypass the capacity check (intentional over-capacity) const now = getNow(); const adminUser = (c as any).get('user'); const hasEmail = !!(data.email && data.email.trim()); const attendeeEmail = hasEmail ? data.email!.trim() : `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`; // Nameless door walk-ins still need a display name on the ticket const firstName = (data.firstName && data.firstName.trim()) || 'Walk-in'; const fullName = data.lastName && data.lastName.trim() ? `${firstName} ${data.lastName.trim()}` : firstName; // Find or create user (see the note on `accountEmail` in the booking route) const accountEmail = normalizeEmail(attendeeEmail); let user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, accountEmail)) ); if (!user) { const userId = generateId(); user = { id: userId, email: accountEmail, password: null, name: fullName, phone: data.phone || null, role: 'user', languagePreference: null, isClaimed: toDbBool(false), accountStatus: 'unclaimed', emailVerified: false, createdAt: now, updatedAt: now, }; await (db as any).insert(users).values(user); } // Check for existing active ticket (only when a real email was provided) if (hasEmail) { 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); } } const ticketId = generateId(); const qrCode = generateTicketCode(); const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'unpaid' ? 'unpaid' : 'paid'; const newTicket = { id: ticketId, userId: user.id, eventId: data.eventId, attendeeFirstName: firstName, attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, attendeeEmail: hasEmail ? data.email!.trim() : null, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, preferredLanguage: data.preferredLanguage || null, status: data.checkinNow ? 'checked_in' : 'confirmed', isGuest: data.type === 'guest' ? 1 : 0, paymentStatus, qrCode, checkinAt: data.checkinNow ? now : null, checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null, adminNote: data.adminNote || null, createdAt: now, }; await (db as any).insert(tickets).values(newTicket); // Payment record: paid cash for paid/door/guest ($0 for guest), pending tpago for unpaid const paymentId = generateId(); const newPayment = data.type === 'unpaid' ? { id: paymentId, ticketId, provider: 'tpago', amount: event.price, currency: event.currency, status: 'pending', reference: 'Unpaid ticket — collect at door', paidAt: null, paidByAdminId: null, createdAt: now, updatedAt: now, } : { id: paymentId, ticketId, provider: 'cash', amount: data.type === 'guest' ? 0 : event.price, currency: event.currency, status: 'paid', reference: data.type === 'guest' ? 'Guest invite' : data.type === 'door' ? 'Paid at door' : 'Manual ticket', paidAt: now, paidByAdminId: adminUser?.id || null, createdAt: now, updatedAt: now, }; await (db as any).insert(payments).values(newPayment); // Emails (asynchronous): paid always confirms; door/guest confirm only when an // email exists; unpaid sends the TPago (Bancard) pay-link instructions instead if (data.type === 'unpaid') { if (hasEmail) { emailService.sendPaymentInstructions(ticketId).then(result => { if (!result.success) { console.error(`[Email] Failed to send pay link for unpaid ticket ${ticketId}:`, result.error); } }).catch(err => { console.error('[Email] Exception sending pay link for unpaid ticket:', err); }); } } else if (data.type === 'paid' || hasEmail) { emailService.sendBookingConfirmation(ticketId).then(result => { if (!result.success) { console.error(`[Email] Failed to send booking confirmation for ${data.type} ticket ${ticketId}:`, result.error); } }).catch(err => { console.error(`[Email] Exception sending booking confirmation for ${data.type} ticket:`, err); }); } const messages: Record = { paid: 'Ticket created — confirmation email sent', door: hasEmail ? 'Ticket created — paid at the door, confirmation email sent' : 'Ticket created — paid at the door', unpaid: hasEmail ? 'Unpaid ticket created — payment link sent' : 'Unpaid ticket created — collect payment at the door', guest: hasEmail ? 'Guest invited — confirmation email sent' : 'Guest invited', }; return c.json({ ticket: { ...newTicket, event: { title: event.title, startDatetime: event.startDatetime, location: event.location, }, }, payment: newPayment, message: data.checkinNow ? `${messages[data.type]} · checked in` : messages[data.type], }, 201); }); // Get all tickets (admin) - includes payment for each ticket 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 ticketsList = await dbAll(query); const ticketIds = ticketsList.map((t: any) => t.id); let paymentByTicketId: Record = {}; if (ticketIds.length > 0) { const paymentsList = await dbAll( (db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds)) ); for (const p of paymentsList as any[]) { paymentByTicketId[p.ticketId] = p; } } const ticketsWithPayment = ticketsList.map((t: any) => ({ ...t, payment: paymentByTicketId[t.id] || null, })); return c.json({ tickets: ticketsWithPayment }); }); export default ticketsRouter;