Backend and frontend updates: auth, email, payments, events, tickets; carrousel images; mobile event detail layout; i18n

This commit is contained in:
Michilis
2026-02-02 20:58:21 +00:00
parent bafd1425c4
commit 4a84ad22c7
44 changed files with 1323 additions and 472 deletions

View File

@@ -7,10 +7,16 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
import emailService from '../lib/email.js';
import { generateTicketPDF } from '../lib/pdf.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),
@@ -20,6 +26,8 @@ const createTicketSchema = z.object({
preferredLanguage: z.enum(['en', 'es']).optional(),
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(),
// Optional: array of attendees for multi-ticket booking
attendees: z.array(attendeeSchema).optional(),
});
const updateTicketSchema = z.object({
@@ -42,10 +50,17 @@ const adminCreateTicketSchema = z.object({
adminNote: z.string().max(1000).optional(),
});
// Book a ticket (public)
// 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))
@@ -58,23 +73,32 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
return c.json({ error: 'Event is not available for booking' }, 400);
}
// Check capacity
const ticketCount = await dbGet<any>(
// 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),
eq((tickets as any).status, 'confirmed')
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
if ((ticketCount?.count || 0) >= event.capacity) {
const availableSeats = event.capacity - (existingTicketCount?.count || 0);
if (availableSeats <= 0) {
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))
@@ -129,55 +153,67 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
}
}
// Create ticket
const ticketId = generateId();
const qrCode = generateTicketCode();
// Generate booking ID to group multiple tickets
const bookingId = generateId();
// Cash payments start as pending, card/lightning start as pending until payment confirmed
const ticketStatus = 'pending';
// Create tickets for each attendee
const createdTickets: any[] = [];
const createdPayments: any[] = [];
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,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: data.ruc || null,
preferredLanguage: data.preferredLanguage || null,
status: ticketStatus,
qrCode,
checkinAt: null,
createdAt: now,
};
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);
}
await (db as any).insert(tickets).values(newTicket);
// Create payment record
const paymentId = generateId();
const newPayment = {
id: paymentId,
ticketId,
provider: data.paymentMethod,
amount: event.price,
currency: event.currency,
status: 'pending',
reference: null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
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(ticketId).then(result => {
emailService.sendPaymentInstructions(primaryTicket.id).then(result => {
if (result.success) {
console.log(`[Email] Payment instructions email sent successfully for ticket ${ticketId}`);
console.log(`[Email] Payment instructions email sent successfully for ticket ${primaryTicket.id}`);
} else {
console.error(`[Email] Failed to send payment instructions email for ticket ${ticketId}:`, result.error);
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);
@@ -186,11 +222,17 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
// If Lightning payment, create LNbits invoice
let lnbitsInvoice = null;
if (data.paymentMethod === 'lightning' && event.price > 0) {
const totalPrice = event.price * ticketCount;
if (data.paymentMethod === 'lightning' && totalPrice > 0) {
if (!isLNbitsConfigured()) {
// Delete the ticket and payment we just created
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
// 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);
@@ -200,49 +242,68 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
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: event.price,
amount: totalPrice,
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
memo: `Spanglish: ${event.title} - ${fullName}`,
memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`,
webhookUrl: `${apiUrl}/api/lnbits/webhook`,
expiry: 900, // 15 minutes expiry for faster UX
extra: {
ticketId,
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 payment with LNbits payment hash reference
// Update primary payment with LNbits payment hash reference
await (db as any)
.update(payments)
.set({ reference: lnbitsInvoice.paymentHash })
.where(eq((payments as any).id, paymentId));
.where(eq((payments as any).id, primaryPayment.id));
(newPayment as any).reference = lnbitsInvoice.paymentHash;
(primaryPayment as any).reference = lnbitsInvoice.paymentHash;
} catch (error: any) {
console.error('Failed to create Lightning invoice:', error);
// Delete the ticket and payment we just created since Lightning payment failed
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
// 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: {
...newTicket,
event: {
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
},
...primaryTicket,
event: eventInfo,
},
payment: newPayment,
// 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,
@@ -251,11 +312,102 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
fiatCurrency: lnbitsInvoice.fiatCurrency,
expiry: lnbitsInvoice.expiry,
} : null,
message: 'Booking created successfully',
message: ticketCount > 1
? `${ticketCount} tickets booked successfully`
: 'Booking created successfully',
}, 201);
});
// Download ticket as PDF
// 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`);
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,
},
}));
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);
@@ -544,6 +696,7 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']),
});
// 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');
@@ -566,22 +719,38 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
const now = getNow();
// Update ticket status
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, id));
// Get all tickets in this booking (if multi-ticket)
let ticketsToConfirm: any[] = [ticket];
// Update payment status
await (db as any)
.update(payments)
.set({
status: 'paid',
paidAt: now,
paidByAdminId: user.id,
updatedAt: now,
})
.where(eq((payments as any).ticketId, id));
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>(
@@ -603,13 +772,20 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
(db as any).select().from(tickets).where(eq((tickets as any).id, id))
);
return c.json({ ticket: updated, message: 'Payment marked as received' });
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))
@@ -666,6 +842,7 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
.set({
status: 'pending_approval',
userMarkedPaidAt: now,
payerName: payerName?.trim() || null,
updatedAt: now,
})
.where(eq((payments as any).id, payment.id));