Files
Spanglish/backend/src/routes/tickets.ts
Michilis fe75912f23 Fix event capacity: no negative availability, sold-out enforcement, admin override
- Backend: use calculateAvailableSeats so availableSeats is never negative
- Backend: reject public booking when confirmed >= capacity; admin create/manual bypass capacity
- Frontend: spotsLeft = max(0, capacity - bookedCount), isSoldOut when bookedCount >= capacity
- Frontend: sold-out redirect on booking page, cap quantity by spotsLeft, never show negative

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 03:01:58 +00:00

1244 lines
37 KiB
TypeScript

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<any>(
(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<any>(
(db as any)
.select({ count: sql<number>`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<any>(
(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<any>(
(db as any)
.select()
.from(paymentOptions)
);
const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false;
if (!allowDuplicateBookings) {
const existingTicket = await dbGet<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
// Get event for response
const event = await dbGet<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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;