- 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>
1244 lines
37 KiB
TypeScript
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;
|