Backend and frontend updates: auth, email, payments, events, tickets; carrousel images; mobile event detail layout; i18n
This commit is contained in:
@@ -169,6 +169,11 @@ async function migrate() {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id TEXT REFERENCES users(id)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Migration: Add booking_id column to tickets for multi-ticket bookings
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
||||
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||
|
||||
@@ -201,6 +206,9 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN admin_note TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN payer_name TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Invoices table
|
||||
await (db as any).run(sql`
|
||||
@@ -534,6 +542,11 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN checked_in_by_admin_id UUID REFERENCES users(id)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Migration: Add booking_id column to tickets for multi-ticket bookings
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
@@ -545,6 +558,7 @@ async function migrate() {
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
reference VARCHAR(255),
|
||||
user_marked_paid_at TIMESTAMP,
|
||||
payer_name VARCHAR(255),
|
||||
paid_at TIMESTAMP,
|
||||
paid_by_admin_id UUID,
|
||||
admin_note TEXT,
|
||||
@@ -552,6 +566,11 @@ async function migrate() {
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Add payer_name column if it doesn't exist
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN payer_name VARCHAR(255)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Invoices table
|
||||
await (db as any).execute(sql`
|
||||
|
||||
@@ -85,6 +85,7 @@ export const sqliteEvents = sqliteTable('events', {
|
||||
|
||||
export const sqliteTickets = sqliteTable('tickets', {
|
||||
id: text('id').primaryKey(),
|
||||
bookingId: text('booking_id'), // Groups multiple tickets from same booking
|
||||
userId: text('user_id').notNull().references(() => sqliteUsers.id),
|
||||
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||
attendeeFirstName: text('attendee_first_name').notNull(),
|
||||
@@ -110,6 +111,7 @@ export const sqlitePayments = sqliteTable('payments', {
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
|
||||
reference: text('reference'),
|
||||
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
|
||||
payerName: text('payer_name'), // Name of payer if different from attendee
|
||||
paidAt: text('paid_at'),
|
||||
paidByAdminId: text('paid_by_admin_id'),
|
||||
adminNote: text('admin_note'), // Internal admin notes
|
||||
@@ -371,6 +373,7 @@ export const pgEvents = pgTable('events', {
|
||||
|
||||
export const pgTickets = pgTable('tickets', {
|
||||
id: uuid('id').primaryKey(),
|
||||
bookingId: uuid('booking_id'), // Groups multiple tickets from same booking
|
||||
userId: uuid('user_id').notNull().references(() => pgUsers.id),
|
||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||
attendeeFirstName: varchar('attendee_first_name', { length: 255 }).notNull(),
|
||||
@@ -396,6 +399,7 @@ export const pgPayments = pgTable('payments', {
|
||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||
reference: varchar('reference', { length: 255 }),
|
||||
userMarkedPaidAt: timestamp('user_marked_paid_at'),
|
||||
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
|
||||
paidAt: timestamp('paid_at'),
|
||||
paidByAdminId: uuid('paid_by_admin_id'),
|
||||
adminNote: pgText('admin_note'),
|
||||
|
||||
@@ -5,7 +5,7 @@ import crypto from 'crypto';
|
||||
import { Context } from 'hono';
|
||||
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
|
||||
import { eq, and, gt } from 'drizzle-orm';
|
||||
import { generateId, getNow } from './utils.js';
|
||||
import { generateId, getNow, toDbDate } from './utils.js';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
|
||||
const JWT_ISSUER = 'spanglish';
|
||||
@@ -51,7 +51,7 @@ export async function createMagicLinkToken(
|
||||
): Promise<string> {
|
||||
const token = generateSecureToken();
|
||||
const now = getNow();
|
||||
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000).toISOString();
|
||||
const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
|
||||
|
||||
await (db as any).insert(magicLinkTokens).values({
|
||||
id: generateId(),
|
||||
@@ -113,7 +113,7 @@ export async function createUserSession(
|
||||
): Promise<string> {
|
||||
const sessionToken = generateSecureToken();
|
||||
const now = getNow();
|
||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days
|
||||
const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days
|
||||
|
||||
await (db as any).insert(userSessions).values({
|
||||
id: generateId(),
|
||||
|
||||
@@ -522,6 +522,7 @@ export const emailService = {
|
||||
|
||||
/**
|
||||
* Send booking confirmation email
|
||||
* Supports multi-ticket bookings - includes all tickets in the booking
|
||||
*/
|
||||
async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get ticket with event info
|
||||
@@ -547,14 +548,37 @@ export const emailService = {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Get all tickets in this booking (if multi-ticket)
|
||||
let allTickets: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
allTickets = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
const ticketCount = allTickets.length;
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
// Generate ticket PDF URL
|
||||
// Generate ticket PDF URL (primary ticket, or use combined endpoint for multi)
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
const ticketPdfUrl = `${apiUrl}/api/tickets/${ticket.id}/pdf`;
|
||||
const ticketPdfUrl = ticketCount > 1 && ticket.bookingId
|
||||
? `${apiUrl}/api/tickets/booking/${ticket.bookingId}/pdf`
|
||||
: `${apiUrl}/api/tickets/${ticket.id}/pdf`;
|
||||
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Build attendee list for multi-ticket emails
|
||||
const attendeeNames = allTickets.map(t =>
|
||||
`${t.attendeeFirstName} ${t.attendeeLastName || ''}`.trim()
|
||||
).join(', ');
|
||||
|
||||
// Calculate total price for multi-ticket bookings
|
||||
const totalPrice = event.price * ticketCount;
|
||||
|
||||
return this.sendTemplateEmail({
|
||||
templateSlug: 'booking-confirmation',
|
||||
to: ticket.attendeeEmail,
|
||||
@@ -565,6 +589,7 @@ export const emailService = {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
bookingId: ticket.bookingId || ticket.id,
|
||||
qrCode: ticket.qrCode || '',
|
||||
ticketPdfUrl,
|
||||
eventTitle,
|
||||
@@ -573,6 +598,11 @@ export const emailService = {
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
eventPrice: this.formatCurrency(event.price, event.currency),
|
||||
// Multi-ticket specific variables
|
||||
ticketCount: ticketCount.toString(),
|
||||
totalPrice: this.formatCurrency(totalPrice, event.currency),
|
||||
attendeeNames,
|
||||
isMultiTicket: ticketCount > 1 ? 'true' : 'false',
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -615,15 +645,48 @@ export const emailService = {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Calculate total amount for multi-ticket bookings
|
||||
let totalAmount = payment.amount;
|
||||
let ticketCount = 1;
|
||||
|
||||
if (ticket.bookingId) {
|
||||
// Get all payments for this booking
|
||||
const bookingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
|
||||
ticketCount = bookingTickets.length;
|
||||
|
||||
// Sum up all payment amounts for the booking
|
||||
const bookingPayments = await Promise.all(
|
||||
bookingTickets.map((t: any) =>
|
||||
dbGet<any>((db as any).select().from(payments).where(eq((payments as any).ticketId, t.id)))
|
||||
)
|
||||
);
|
||||
|
||||
totalAmount = bookingPayments
|
||||
.filter((p: any) => p)
|
||||
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
|
||||
}
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
const paymentMethodNames: Record<string, Record<string, string>> = {
|
||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash' },
|
||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo' },
|
||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
|
||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
|
||||
};
|
||||
|
||||
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Format amount with ticket count info for multi-ticket bookings
|
||||
const amountDisplay = ticketCount > 1
|
||||
? `${this.formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)`
|
||||
: this.formatCurrency(totalAmount, payment.currency);
|
||||
|
||||
return this.sendTemplateEmail({
|
||||
templateSlug: 'payment-receipt',
|
||||
to: ticket.attendeeEmail,
|
||||
@@ -632,10 +695,10 @@ export const emailService = {
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: receiptFullName,
|
||||
ticketId: ticket.id,
|
||||
ticketId: ticket.bookingId || ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
paymentAmount: this.formatCurrency(payment.amount, payment.currency),
|
||||
paymentAmount: amountDisplay,
|
||||
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
|
||||
paymentReference: payment.reference || payment.id,
|
||||
paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale),
|
||||
@@ -750,8 +813,24 @@ export const emailService = {
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Generate a payment reference using ticket ID
|
||||
const paymentReference = `SPG-${ticket.id.substring(0, 8).toUpperCase()}`;
|
||||
// Calculate total price for multi-ticket bookings
|
||||
let totalPrice = event.price;
|
||||
let ticketCount = 1;
|
||||
|
||||
if (ticket.bookingId) {
|
||||
// Count all tickets in this booking
|
||||
const bookingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
ticketCount = bookingTickets.length;
|
||||
totalPrice = event.price * ticketCount;
|
||||
}
|
||||
|
||||
// Generate a payment reference using booking ID or ticket ID
|
||||
const paymentReference = `SPG-${(ticket.bookingId || ticket.id).substring(0, 8).toUpperCase()}`;
|
||||
|
||||
// Generate the booking URL for returning to payment page
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
@@ -762,17 +841,22 @@ export const emailService = {
|
||||
? 'payment-instructions-tpago'
|
||||
: 'payment-instructions-bank-transfer';
|
||||
|
||||
// Format amount with ticket count info for multi-ticket bookings
|
||||
const amountDisplay = ticketCount > 1
|
||||
? `${this.formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
|
||||
: this.formatCurrency(totalPrice, event.currency);
|
||||
|
||||
// Build variables based on payment method
|
||||
const variables: Record<string, any> = {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
ticketId: ticket.bookingId || ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale),
|
||||
eventTime: this.formatTime(event.startDatetime, locale),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
paymentAmount: this.formatCurrency(event.price, event.currency),
|
||||
paymentAmount: amountDisplay,
|
||||
paymentReference,
|
||||
bookingUrl,
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
.where(eq((payments as any).status, 'paid'))
|
||||
);
|
||||
|
||||
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
|
||||
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
|
||||
|
||||
const newContacts = await dbGet<any>(
|
||||
(db as any)
|
||||
|
||||
@@ -136,6 +136,8 @@ eventsRouter.get('/', async (c) => {
|
||||
// Get ticket counts for each event
|
||||
const eventsWithCounts = await Promise.all(
|
||||
result.map(async (event: any) => {
|
||||
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||
// This ensures check-in doesn't affect capacity/spots_left
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -143,7 +145,7 @@ eventsRouter.get('/', async (c) => {
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -172,7 +174,8 @@ eventsRouter.get('/:id', async (c) => {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Get ticket count
|
||||
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||
// This ensures check-in doesn't affect capacity/spots_left
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -180,7 +183,7 @@ eventsRouter.get('/:id', async (c) => {
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, id),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -217,6 +220,8 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
||||
return c.json({ event: null });
|
||||
}
|
||||
|
||||
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||
// This ensures check-in doesn't affect capacity/spots_left
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -224,7 +229,7 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'confirmed')
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { db, dbGet, tickets, payments } from '../db/index.js';
|
||||
import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
|
||||
@@ -152,40 +152,63 @@ lnbitsRouter.post('/webhook', async (c) => {
|
||||
|
||||
/**
|
||||
* Handle successful payment
|
||||
* Supports multi-ticket bookings - confirms all tickets in the booking
|
||||
*/
|
||||
async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||
const now = getNow();
|
||||
|
||||
// Check if already confirmed to avoid duplicate updates
|
||||
// Get the ticket to check for booking ID
|
||||
const existingTicket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
|
||||
if (existingTicket?.status === 'confirmed') {
|
||||
if (!existingTicket) {
|
||||
console.error(`Ticket ${ticketId} not found for payment confirmation`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingTicket.status === 'confirmed') {
|
||||
console.log(`Ticket ${ticketId} already confirmed, skipping update`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, ticketId));
|
||||
// Get all tickets in this booking (if multi-ticket)
|
||||
let ticketsToConfirm: any[] = [existingTicket];
|
||||
|
||||
// Update payment status to paid
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
reference: paymentHash,
|
||||
paidAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, ticketId));
|
||||
if (existingTicket.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, existingTicket.bookingId))
|
||||
);
|
||||
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
|
||||
}
|
||||
|
||||
console.log(`Ticket ${ticketId} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||
// Confirm all tickets in the booking
|
||||
for (const ticket of ticketsToConfirm) {
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, ticket.id));
|
||||
|
||||
// Update payment status to paid
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
reference: paymentHash,
|
||||
paidAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, ticket.id));
|
||||
|
||||
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||
}
|
||||
|
||||
// Get payment for sending receipt
|
||||
// Get primary payment for sending receipt
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
@@ -194,6 +217,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||
);
|
||||
|
||||
// Send confirmation emails asynchronously
|
||||
// For multi-ticket bookings, send email with all ticket info
|
||||
Promise.all([
|
||||
emailService.sendBookingConfirmation(ticketId),
|
||||
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
|
||||
|
||||
@@ -8,13 +8,19 @@ import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
|
||||
|
||||
const paymentOptionsRouter = new Hono();
|
||||
|
||||
// Helper to normalize boolean (handles true/false and 0/1 from database)
|
||||
const booleanOrNumber = z.union([z.boolean(), z.number()]).transform((val) => {
|
||||
if (typeof val === 'boolean') return val;
|
||||
return val !== 0;
|
||||
});
|
||||
|
||||
// Schema for updating global payment options
|
||||
const updatePaymentOptionsSchema = z.object({
|
||||
tpagoEnabled: z.boolean().optional(),
|
||||
tpagoEnabled: booleanOrNumber.optional(),
|
||||
tpagoLink: z.string().optional().nullable(),
|
||||
tpagoInstructions: z.string().optional().nullable(),
|
||||
tpagoInstructionsEs: z.string().optional().nullable(),
|
||||
bankTransferEnabled: z.boolean().optional(),
|
||||
bankTransferEnabled: booleanOrNumber.optional(),
|
||||
bankName: z.string().optional().nullable(),
|
||||
bankAccountHolder: z.string().optional().nullable(),
|
||||
bankAccountNumber: z.string().optional().nullable(),
|
||||
@@ -22,21 +28,21 @@ const updatePaymentOptionsSchema = z.object({
|
||||
bankPhone: z.string().optional().nullable(),
|
||||
bankNotes: z.string().optional().nullable(),
|
||||
bankNotesEs: z.string().optional().nullable(),
|
||||
lightningEnabled: z.boolean().optional(),
|
||||
cashEnabled: z.boolean().optional(),
|
||||
lightningEnabled: booleanOrNumber.optional(),
|
||||
cashEnabled: booleanOrNumber.optional(),
|
||||
cashInstructions: z.string().optional().nullable(),
|
||||
cashInstructionsEs: z.string().optional().nullable(),
|
||||
// Booking settings
|
||||
allowDuplicateBookings: z.boolean().optional(),
|
||||
allowDuplicateBookings: booleanOrNumber.optional(),
|
||||
});
|
||||
|
||||
// Schema for event-level overrides
|
||||
const updateEventOverridesSchema = z.object({
|
||||
tpagoEnabled: z.boolean().optional().nullable(),
|
||||
tpagoEnabled: booleanOrNumber.optional().nullable(),
|
||||
tpagoLink: z.string().optional().nullable(),
|
||||
tpagoInstructions: z.string().optional().nullable(),
|
||||
tpagoInstructionsEs: z.string().optional().nullable(),
|
||||
bankTransferEnabled: z.boolean().optional().nullable(),
|
||||
bankTransferEnabled: booleanOrNumber.optional().nullable(),
|
||||
bankName: z.string().optional().nullable(),
|
||||
bankAccountHolder: z.string().optional().nullable(),
|
||||
bankAccountNumber: z.string().optional().nullable(),
|
||||
@@ -44,8 +50,8 @@ const updateEventOverridesSchema = z.object({
|
||||
bankPhone: z.string().optional().nullable(),
|
||||
bankNotes: z.string().optional().nullable(),
|
||||
bankNotesEs: z.string().optional().nullable(),
|
||||
lightningEnabled: z.boolean().optional().nullable(),
|
||||
cashEnabled: z.boolean().optional().nullable(),
|
||||
lightningEnabled: booleanOrNumber.optional().nullable(),
|
||||
cashEnabled: booleanOrNumber.optional().nullable(),
|
||||
cashInstructions: z.string().optional().nullable(),
|
||||
cashInstructionsEs: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -76,6 +76,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
...payment,
|
||||
ticket: ticket ? {
|
||||
id: ticket.id,
|
||||
bookingId: ticket.bookingId,
|
||||
attendeeFirstName: ticket.attendeeFirstName,
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
@@ -128,6 +129,7 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
|
||||
...payment,
|
||||
ticket: ticket ? {
|
||||
id: ticket.id,
|
||||
bookingId: ticket.bookingId,
|
||||
attendeeFirstName: ticket.attendeeFirstName,
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
@@ -199,17 +201,42 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
updateData.paidByAdminId = user.id;
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set(updateData)
|
||||
.where(eq((payments as any).id, id));
|
||||
|
||||
// If payment confirmed, update ticket status and send emails
|
||||
// If payment confirmed, handle multi-ticket booking
|
||||
if (data.status === 'paid') {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, existing.ticketId));
|
||||
// Get the ticket associated with this payment
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, existing.ticketId))
|
||||
);
|
||||
|
||||
// Check if this is part of a multi-ticket booking
|
||||
let ticketsToConfirm: any[] = [ticket];
|
||||
|
||||
if (ticket?.bookingId) {
|
||||
// Get all tickets in this booking
|
||||
ticketsToConfirm = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
console.log(`[Payment] Confirming multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||
}
|
||||
|
||||
// Update all payments and tickets in the booking
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set(updateData)
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
|
||||
// Send confirmation emails asynchronously (don't block the response)
|
||||
Promise.all([
|
||||
@@ -218,6 +245,12 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
]).catch(err => {
|
||||
console.error('[Email] Failed to send confirmation emails:', err);
|
||||
});
|
||||
} else {
|
||||
// For non-paid status updates, just update this payment
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set(updateData)
|
||||
.where(eq((payments as any).id, id));
|
||||
}
|
||||
|
||||
const updated = await dbGet(
|
||||
@@ -254,23 +287,47 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Update payment status to paid
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).id, id));
|
||||
// Get the ticket associated with this payment
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, payment.ticketId));
|
||||
// Check if this is part of a multi-ticket booking
|
||||
let ticketsToConfirm: any[] = [ticket];
|
||||
|
||||
if (ticket?.bookingId) {
|
||||
// Get all tickets in this booking
|
||||
ticketsToConfirm = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||
}
|
||||
|
||||
// Update all payments in the booking to paid
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
|
||||
// Send confirmation emails asynchronously
|
||||
Promise.all([
|
||||
@@ -453,7 +510,7 @@ paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
failed: allPayments.filter((p: any) => p.status === 'failed').length,
|
||||
totalRevenue: allPayments
|
||||
.filter((p: any) => p.status === 'paid')
|
||||
.reduce((sum: number, p: any) => sum + (p.amount || 0), 0),
|
||||
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0),
|
||||
};
|
||||
|
||||
return c.json({ stats });
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user