653 lines
19 KiB
TypeScript
653 lines
19 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { zValidator } from '@hono/zod-validator';
|
|
import { z } from 'zod';
|
|
import { db, tickets, events, users, payments } from '../db/index.js';
|
|
import { eq, and, sql } from 'drizzle-orm';
|
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
|
import { generateId, generateTicketCode, getNow } from '../lib/utils.js';
|
|
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
|
import emailService from '../lib/email.js';
|
|
|
|
const ticketsRouter = new Hono();
|
|
|
|
const createTicketSchema = z.object({
|
|
eventId: z.string(),
|
|
firstName: z.string().min(2),
|
|
lastName: z.string().min(2),
|
|
email: z.string().email(),
|
|
phone: z.string().min(6, 'Phone number is required'),
|
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
|
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
|
ruc: z.string().regex(/^[0-9]{6,8}-[0-9]{1}$/, 'Invalid RUC format').optional(),
|
|
});
|
|
|
|
const updateTicketSchema = z.object({
|
|
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
|
|
adminNote: z.string().optional(),
|
|
});
|
|
|
|
const updateNoteSchema = z.object({
|
|
note: z.string().max(1000),
|
|
});
|
|
|
|
const adminCreateTicketSchema = z.object({
|
|
eventId: z.string(),
|
|
firstName: z.string().min(2),
|
|
lastName: z.string().optional().or(z.literal('')),
|
|
email: z.string().email().optional().or(z.literal('')),
|
|
phone: z.string().optional().or(z.literal('')),
|
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
|
autoCheckin: z.boolean().optional().default(false),
|
|
adminNote: z.string().max(1000).optional(),
|
|
});
|
|
|
|
// Book a ticket (public)
|
|
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|
const data = c.req.valid('json');
|
|
|
|
// Get event
|
|
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get();
|
|
if (!event) {
|
|
return c.json({ error: 'Event not found' }, 404);
|
|
}
|
|
|
|
if (event.status !== 'published') {
|
|
return c.json({ error: 'Event is not available for booking' }, 400);
|
|
}
|
|
|
|
// Check capacity
|
|
const ticketCount = await (db as any)
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(tickets)
|
|
.where(
|
|
and(
|
|
eq((tickets as any).eventId, data.eventId),
|
|
eq((tickets as any).status, 'confirmed')
|
|
)
|
|
)
|
|
.get();
|
|
|
|
if ((ticketCount?.count || 0) >= event.capacity) {
|
|
return c.json({ error: 'Event is sold out' }, 400);
|
|
}
|
|
|
|
// Find or create user
|
|
let user = await (db as any).select().from(users).where(eq((users as any).email, data.email)).get();
|
|
|
|
const now = getNow();
|
|
|
|
const fullName = `${data.firstName} ${data.lastName}`.trim();
|
|
|
|
if (!user) {
|
|
const userId = generateId();
|
|
user = {
|
|
id: userId,
|
|
email: data.email,
|
|
password: '', // No password for guest bookings
|
|
name: fullName,
|
|
phone: data.phone || null,
|
|
role: 'user',
|
|
languagePreference: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
await (db as any).insert(users).values(user);
|
|
}
|
|
|
|
// Check for duplicate booking
|
|
const existingTicket = await (db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(
|
|
and(
|
|
eq((tickets as any).userId, user.id),
|
|
eq((tickets as any).eventId, data.eventId)
|
|
)
|
|
)
|
|
.get();
|
|
|
|
if (existingTicket && existingTicket.status !== 'cancelled') {
|
|
return c.json({ error: 'You have already booked this event' }, 400);
|
|
}
|
|
|
|
// Create ticket
|
|
const ticketId = generateId();
|
|
const qrCode = generateTicketCode();
|
|
|
|
// Cash payments start as pending, card/lightning start as pending until payment confirmed
|
|
const ticketStatus = 'pending';
|
|
|
|
const newTicket = {
|
|
id: ticketId,
|
|
userId: user.id,
|
|
eventId: data.eventId,
|
|
attendeeFirstName: data.firstName,
|
|
attendeeLastName: data.lastName,
|
|
attendeeEmail: data.email,
|
|
attendeePhone: data.phone,
|
|
attendeeRuc: data.ruc || null,
|
|
preferredLanguage: data.preferredLanguage || null,
|
|
status: ticketStatus,
|
|
qrCode,
|
|
checkinAt: null,
|
|
createdAt: now,
|
|
};
|
|
|
|
await (db as any).insert(tickets).values(newTicket);
|
|
|
|
// Create payment record
|
|
const paymentId = generateId();
|
|
const newPayment = {
|
|
id: paymentId,
|
|
ticketId,
|
|
provider: data.paymentMethod,
|
|
amount: event.price,
|
|
currency: event.currency,
|
|
status: 'pending',
|
|
reference: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
await (db as any).insert(payments).values(newPayment);
|
|
|
|
// If Lightning payment, create LNbits invoice
|
|
let lnbitsInvoice = null;
|
|
if (data.paymentMethod === 'lightning' && event.price > 0) {
|
|
if (!isLNbitsConfigured()) {
|
|
// Delete the ticket and payment we just created
|
|
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
|
|
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
|
|
return c.json({
|
|
error: 'Bitcoin Lightning payments are not available at this time'
|
|
}, 400);
|
|
}
|
|
|
|
try {
|
|
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
|
|
|
// Pass the fiat currency directly to LNbits - it handles conversion automatically
|
|
lnbitsInvoice = await createInvoice({
|
|
amount: event.price,
|
|
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
|
|
memo: `Spanglish: ${event.title} - ${fullName}`,
|
|
webhookUrl: `${apiUrl}/api/lnbits/webhook`,
|
|
expiry: 900, // 15 minutes expiry for faster UX
|
|
extra: {
|
|
ticketId,
|
|
eventId: event.id,
|
|
eventTitle: event.title,
|
|
attendeeName: fullName,
|
|
attendeeEmail: data.email,
|
|
},
|
|
});
|
|
|
|
// Update payment with LNbits payment hash reference
|
|
await (db as any)
|
|
.update(payments)
|
|
.set({ reference: lnbitsInvoice.paymentHash })
|
|
.where(eq((payments as any).id, paymentId));
|
|
|
|
(newPayment as any).reference = lnbitsInvoice.paymentHash;
|
|
} catch (error: any) {
|
|
console.error('Failed to create Lightning invoice:', error);
|
|
// Delete the ticket and payment we just created since Lightning payment failed
|
|
await (db as any).delete(payments).where(eq((payments as any).id, paymentId));
|
|
await (db as any).delete(tickets).where(eq((tickets as any).id, ticketId));
|
|
return c.json({
|
|
error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
return c.json({
|
|
ticket: {
|
|
...newTicket,
|
|
event: {
|
|
title: event.title,
|
|
startDatetime: event.startDatetime,
|
|
location: event.location,
|
|
},
|
|
},
|
|
payment: newPayment,
|
|
lightningInvoice: lnbitsInvoice ? {
|
|
paymentHash: lnbitsInvoice.paymentHash,
|
|
paymentRequest: lnbitsInvoice.paymentRequest,
|
|
amount: lnbitsInvoice.amount, // Amount in satoshis
|
|
fiatAmount: lnbitsInvoice.fiatAmount,
|
|
fiatCurrency: lnbitsInvoice.fiatCurrency,
|
|
expiry: lnbitsInvoice.expiry,
|
|
} : null,
|
|
message: 'Booking created successfully',
|
|
}, 201);
|
|
});
|
|
|
|
// Get ticket by ID
|
|
ticketsRouter.get('/:id', async (c) => {
|
|
const id = c.req.param('id');
|
|
|
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
// Get associated event
|
|
const event = await (db as any).select().from(events).where(eq((events as any).id, ticket.eventId)).get();
|
|
|
|
// Get payment
|
|
const payment = await (db as any).select().from(payments).where(eq((payments as any).ticketId, id)).get();
|
|
|
|
return c.json({
|
|
ticket: {
|
|
...ticket,
|
|
event,
|
|
payment,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Update ticket status (admin/organizer)
|
|
ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateTicketSchema), async (c) => {
|
|
const id = c.req.param('id');
|
|
const data = c.req.valid('json');
|
|
|
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
const updates: any = {};
|
|
|
|
if (data.status) {
|
|
updates.status = data.status;
|
|
if (data.status === 'checked_in') {
|
|
updates.checkinAt = getNow();
|
|
}
|
|
}
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
await (db as any).update(tickets).set(updates).where(eq((tickets as any).id, id));
|
|
}
|
|
|
|
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
return c.json({ ticket: updated });
|
|
});
|
|
|
|
// Check-in ticket
|
|
ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
|
const id = c.req.param('id');
|
|
|
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
if (ticket.status === 'checked_in') {
|
|
return c.json({ error: 'Ticket already checked in' }, 400);
|
|
}
|
|
|
|
if (ticket.status !== 'confirmed') {
|
|
return c.json({ error: 'Ticket must be confirmed before check-in' }, 400);
|
|
}
|
|
|
|
await (db as any)
|
|
.update(tickets)
|
|
.set({ status: 'checked_in', checkinAt: getNow() })
|
|
.where(eq((tickets as any).id, id));
|
|
|
|
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
return c.json({ ticket: updated, message: 'Check-in successful' });
|
|
});
|
|
|
|
// Mark payment as received (for cash payments - admin only)
|
|
ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
|
const id = c.req.param('id');
|
|
const user = (c as any).get('user');
|
|
|
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
if (ticket.status === 'confirmed') {
|
|
return c.json({ error: 'Ticket already confirmed' }, 400);
|
|
}
|
|
|
|
if (ticket.status === 'cancelled') {
|
|
return c.json({ error: 'Cannot confirm cancelled ticket' }, 400);
|
|
}
|
|
|
|
const now = getNow();
|
|
|
|
// Update ticket status
|
|
await (db as any)
|
|
.update(tickets)
|
|
.set({ status: 'confirmed' })
|
|
.where(eq((tickets as any).id, id));
|
|
|
|
// Update payment status
|
|
await (db as any)
|
|
.update(payments)
|
|
.set({
|
|
status: 'paid',
|
|
paidAt: now,
|
|
paidByAdminId: user.id,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((payments as any).ticketId, id));
|
|
|
|
// Get payment for sending receipt
|
|
const payment = await (db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).ticketId, id))
|
|
.get();
|
|
|
|
// Send confirmation emails asynchronously (don't block the response)
|
|
Promise.all([
|
|
emailService.sendBookingConfirmation(id),
|
|
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
|
|
]).catch(err => {
|
|
console.error('[Email] Failed to send confirmation emails:', err);
|
|
});
|
|
|
|
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
return c.json({ ticket: updated, message: 'Payment marked as received' });
|
|
});
|
|
|
|
// User marks payment as sent (for manual payment methods: bank_transfer, tpago)
|
|
// This sets status to "pending_approval" and notifies admin
|
|
ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
|
|
const id = c.req.param('id');
|
|
|
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
// Get the payment
|
|
const payment = await (db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).ticketId, id))
|
|
.get();
|
|
|
|
if (!payment) {
|
|
return c.json({ error: 'Payment not found' }, 404);
|
|
}
|
|
|
|
// Only allow for manual payment methods
|
|
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
|
|
return c.json({ error: 'This action is only available for bank transfer or TPago payments' }, 400);
|
|
}
|
|
|
|
// Only allow if currently pending
|
|
if (payment.status !== 'pending') {
|
|
return c.json({ error: 'Payment has already been processed' }, 400);
|
|
}
|
|
|
|
const now = getNow();
|
|
|
|
// Update payment status to pending_approval
|
|
await (db as any)
|
|
.update(payments)
|
|
.set({
|
|
status: 'pending_approval',
|
|
userMarkedPaidAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((payments as any).id, payment.id));
|
|
|
|
// Get updated payment
|
|
const updatedPayment = await (db as any)
|
|
.select()
|
|
.from(payments)
|
|
.where(eq((payments as any).id, payment.id))
|
|
.get();
|
|
|
|
// TODO: Send notification to admin about pending payment approval
|
|
|
|
return c.json({
|
|
payment: updatedPayment,
|
|
message: 'Payment marked as sent. Waiting for admin approval.'
|
|
});
|
|
});
|
|
|
|
// Cancel ticket
|
|
ticketsRouter.post('/:id/cancel', async (c) => {
|
|
const id = c.req.param('id');
|
|
const user = await getAuthUser(c);
|
|
|
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
// Check authorization (admin or ticket owner)
|
|
if (!user || (user.role !== 'admin' && user.id !== ticket.userId)) {
|
|
return c.json({ error: 'Unauthorized' }, 403);
|
|
}
|
|
|
|
if (ticket.status === 'cancelled') {
|
|
return c.json({ error: 'Ticket already cancelled' }, 400);
|
|
}
|
|
|
|
await (db as any).update(tickets).set({ status: 'cancelled' }).where(eq((tickets as any).id, id));
|
|
|
|
return c.json({ message: 'Ticket cancelled successfully' });
|
|
});
|
|
|
|
// Remove check-in (reset to confirmed)
|
|
ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
|
const id = c.req.param('id');
|
|
|
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
if (ticket.status !== 'checked_in') {
|
|
return c.json({ error: 'Ticket is not checked in' }, 400);
|
|
}
|
|
|
|
await (db as any)
|
|
.update(tickets)
|
|
.set({ status: 'confirmed', checkinAt: null })
|
|
.where(eq((tickets as any).id, id));
|
|
|
|
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
return c.json({ ticket: updated, message: 'Check-in removed successfully' });
|
|
});
|
|
|
|
// Update admin note
|
|
ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateNoteSchema), async (c) => {
|
|
const id = c.req.param('id');
|
|
const { note } = c.req.valid('json');
|
|
|
|
const ticket = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
if (!ticket) {
|
|
return c.json({ error: 'Ticket not found' }, 404);
|
|
}
|
|
|
|
await (db as any)
|
|
.update(tickets)
|
|
.set({ adminNote: note || null })
|
|
.where(eq((tickets as any).id, id));
|
|
|
|
const updated = await (db as any).select().from(tickets).where(eq((tickets as any).id, id)).get();
|
|
|
|
return c.json({ ticket: updated, message: 'Note updated successfully' });
|
|
});
|
|
|
|
// Admin create ticket (at the door)
|
|
ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', adminCreateTicketSchema), async (c) => {
|
|
const data = c.req.valid('json');
|
|
|
|
// Get event
|
|
const event = await (db as any).select().from(events).where(eq((events as any).id, data.eventId)).get();
|
|
if (!event) {
|
|
return c.json({ error: 'Event not found' }, 404);
|
|
}
|
|
|
|
// Check capacity
|
|
const ticketCount = await (db as any)
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(tickets)
|
|
.where(
|
|
and(
|
|
eq((tickets as any).eventId, data.eventId),
|
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
|
)
|
|
)
|
|
.get();
|
|
|
|
if ((ticketCount?.count || 0) >= event.capacity) {
|
|
return c.json({ error: 'Event is at capacity' }, 400);
|
|
}
|
|
|
|
const now = getNow();
|
|
|
|
// For door sales, email might be empty - use a generated placeholder
|
|
const attendeeEmail = data.email && data.email.trim()
|
|
? data.email.trim()
|
|
: `door-${generateId()}@doorentry.local`;
|
|
|
|
// Find or create user
|
|
let user = await (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)).get();
|
|
|
|
const adminFullName = data.lastName && data.lastName.trim()
|
|
? `${data.firstName} ${data.lastName}`.trim()
|
|
: data.firstName;
|
|
|
|
if (!user) {
|
|
const userId = generateId();
|
|
user = {
|
|
id: userId,
|
|
email: attendeeEmail,
|
|
password: '',
|
|
name: adminFullName,
|
|
phone: data.phone || null,
|
|
role: 'user',
|
|
languagePreference: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
await (db as any).insert(users).values(user);
|
|
}
|
|
|
|
// Check for existing active ticket for this user and event (only if real email provided)
|
|
if (data.email && data.email.trim() && !data.email.includes('@doorentry.local')) {
|
|
const existingTicket = await (db as any)
|
|
.select()
|
|
.from(tickets)
|
|
.where(
|
|
and(
|
|
eq((tickets as any).userId, user.id),
|
|
eq((tickets as any).eventId, data.eventId)
|
|
)
|
|
)
|
|
.get();
|
|
|
|
if (existingTicket && existingTicket.status !== 'cancelled') {
|
|
return c.json({ error: 'This person already has a ticket for this event' }, 400);
|
|
}
|
|
}
|
|
|
|
// Create ticket
|
|
const ticketId = generateId();
|
|
const qrCode = generateTicketCode();
|
|
|
|
// For door sales, mark as confirmed (or checked_in if auto-checkin)
|
|
const ticketStatus = data.autoCheckin ? 'checked_in' : 'confirmed';
|
|
|
|
const newTicket = {
|
|
id: ticketId,
|
|
userId: user.id,
|
|
eventId: data.eventId,
|
|
attendeeFirstName: data.firstName,
|
|
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
|
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
|
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
|
preferredLanguage: data.preferredLanguage || null,
|
|
status: ticketStatus,
|
|
qrCode,
|
|
checkinAt: data.autoCheckin ? now : null,
|
|
adminNote: data.adminNote || null,
|
|
createdAt: now,
|
|
};
|
|
|
|
await (db as any).insert(tickets).values(newTicket);
|
|
|
|
// Create payment record (marked as paid for door sales)
|
|
const paymentId = generateId();
|
|
const adminUser = (c as any).get('user');
|
|
const newPayment = {
|
|
id: paymentId,
|
|
ticketId,
|
|
provider: 'cash',
|
|
amount: event.price,
|
|
currency: event.currency,
|
|
status: 'paid',
|
|
reference: 'Door sale',
|
|
paidAt: now,
|
|
paidByAdminId: adminUser?.id || null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
await (db as any).insert(payments).values(newPayment);
|
|
|
|
return c.json({
|
|
ticket: {
|
|
...newTicket,
|
|
event: {
|
|
title: event.title,
|
|
startDatetime: event.startDatetime,
|
|
location: event.location,
|
|
},
|
|
},
|
|
payment: newPayment,
|
|
message: data.autoCheckin
|
|
? 'Attendee added and checked in successfully'
|
|
: 'Attendee added successfully',
|
|
}, 201);
|
|
});
|
|
|
|
// Get all tickets (admin)
|
|
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const eventId = c.req.query('eventId');
|
|
const status = c.req.query('status');
|
|
|
|
let query = (db as any).select().from(tickets);
|
|
|
|
const conditions = [];
|
|
if (eventId) {
|
|
conditions.push(eq((tickets as any).eventId, eventId));
|
|
}
|
|
if (status) {
|
|
conditions.push(eq((tickets as any).status, status));
|
|
}
|
|
|
|
if (conditions.length > 0) {
|
|
query = query.where(and(...conditions));
|
|
}
|
|
|
|
const result = await query.all();
|
|
|
|
return c.json({ tickets: result });
|
|
});
|
|
|
|
export default ticketsRouter;
|