Files
Spanglish/backend/src/routes/tickets.ts
T
MichilisandClaude Opus 5 87bf9a6151 Fix Google sign-in for existing email and ticket-buyer accounts.
Google sign-in only worked for people who already had a linked google
row in auth_accounts. Anyone who first appeared another way -- a guest
ticket purchase, or an email/password signup made after the Better Auth
migration -- got a 401 "account not linked".

trustedProviders: ['google'] defeats only one of better-auth's two
linking gates. The second, requireLocalEmailVerified, defaults to true
and refuses the link whenever the LOCAL users.email_verified is false,
independently of whether the provider is trusted. That flag is false for
every guest-booking row and for every post-migration signup, since
requireEmailVerification is off and no verification mail is sent.

Turn that gate off: the Google id_token is signature-verified against
Google's JWKS with issuer/audience/max-age checks and carries its own
email_verified, so the local column proves nothing extra here.

Linking alone was not enough. getAuthUser() rejects any session whose
user is not 'active', so a ticket buyer would link Google, receive a
cookie, and still look logged out. A databaseHooks.account.create.after
hook now promotes unclaimed rows to claimed/active when a google account
is attached, scoped in the WHERE clause so a suspended account is never
reactivated this way.

Also normalize users.email. The unique index is case-sensitive while
better-auth lowercases every lookup, so someone who booked as
John@Gmail.com was invisible to sign-in and Google minted a SECOND user
row, stranding their tickets on the first. normalizeEmail() covers the
find-or-create sites in tickets.ts and door.ts plus the claim-eligibility
lookup, and an idempotent migration lowercases existing rows -- skipping
any that would collide and reporting those for manual merge, since
merging two people's tickets and payments is not a migration's call.
tickets.attendeeEmail still stores the address exactly as typed.

Tests drive the real signInSocial id-token path with Google stubbed by
signing tokens with a throwaway RS256 key and serving our own JWKS, so
the actual verification runs without network or credentials. That also
makes the deprecation risk loud: requireLocalEmailVerified is marked for
removal upstream, and an upgrade that drops it now fails CI instead of
silently locking ticket buyers out again.

Frontend carries error.code through so OAUTH_LINK_ERROR renders an
actionable message in both locales rather than a bare "account not
linked".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:31:02 +00:00

1786 lines
58 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, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import emailService from '../lib/email.js';
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
import { seatHolderCountQuery } from '../lib/capacity.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('')),
});
// Maximum tickets a single buyer can book at once (enforced server-side)
const MAX_TICKETS_PER_BOOKING = 5;
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(),
// 'bancard' intentionally excluded: no checkout integration exists for it
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
// Base + optional "-" + check digit; digits-only kept for older clients, normalized to dashed form on save
ruc: z.string().regex(/^(\d{6,10}|\d{5,8}-\d)$/, 'Invalid RUC format').optional().or(z.literal('')),
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
});
// Canonical stored RUC form is "base-checkdigit" (e.g. 1234567-9); older clients send digits only
function normalizeRuc(ruc: string | undefined): string | null {
if (!ruc) return null;
if (ruc.includes('-')) return ruc;
return `${ruc.slice(0, -1)}-${ruc.slice(-1)}`;
}
// Maps a payment provider to the merged payment-option flag that enables it
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
const truthy = (v: any) => v === true || v === 1;
switch (method) {
case 'tpago':
return truthy(merged.tpagoEnabled);
case 'bank_transfer':
return truthy(merged.bankTransferEnabled);
case 'lightning':
return truthy(merged.lightningEnabled);
case 'cash':
return truthy(merged.cashEnabled);
default:
return false;
}
}
const updateTicketSchema = z.object({
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold']).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');
const rucNumber = normalizeRuc(data.ruc);
// 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;
// Enforce the per-booking ticket cap server-side (UI also caps, but the API is authoritative)
if (ticketCount < 1 || ticketCount > MAX_TICKETS_PER_BOOKING) {
return c.json({ error: `You can book between 1 and ${MAX_TICKETS_PER_BOOKING} tickets per order.` }, 400);
}
// 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 (!['published', 'unlisted'].includes(event.status)) {
return c.json({ error: 'Event is not available for booking' }, 400);
}
// Validate the requested payment method is actually enabled for this event
// (merge global options with any event-level overrides; override wins when not null)
const globalPaymentOptions = await dbGet<any>(
(db as any).select().from(paymentOptions)
);
const eventOverrides = await dbGet<any>(
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, data.eventId))
);
const mergedPaymentOptions: Record<string, any> = {
tpagoEnabled: eventOverrides?.tpagoEnabled ?? globalPaymentOptions?.tpagoEnabled ?? false,
bankTransferEnabled: eventOverrides?.bankTransferEnabled ?? globalPaymentOptions?.bankTransferEnabled ?? false,
lightningEnabled: eventOverrides?.lightningEnabled ?? globalPaymentOptions?.lightningEnabled ?? true,
cashEnabled: eventOverrides?.cashEnabled ?? globalPaymentOptions?.cashEnabled ?? true,
};
if (!isPaymentMethodEnabled(data.paymentMethod, mergedPaymentOptions)) {
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
}
// Check capacity against held seats (paid/checked-in tickets plus claimed
// manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat.
const existingTicketCount = await dbGet<any>(
seatHolderCountQuery(db, data.eventId)
);
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. The account row is keyed on the normalized address so
// it stays reachable from Better Auth (which lowercases every lookup) —
// tickets.attendeeEmail below keeps the address exactly as the buyer typed it.
const accountEmail = normalizeEmail(data.email);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
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: accountEmail,
password: null, // No password for guest bookings; set on claim (Better Auth credential account)
name: fullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
rucNumber,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(user);
} else if (rucNumber) {
// Keep the user's saved RUC up to date for future bookings, but never blank
// out an existing value if this booking didn't include one.
await (db as any)
.update(users)
.set({ rucNumber, updatedAt: now })
.where(eq((users as any).id, user.id));
user.rucNumber = rucNumber;
}
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
const allowDuplicateBookings = globalPaymentOptions?.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();
// Atomically re-check capacity and insert tickets/payments inside a transaction
// so concurrent bookings cannot oversell the same seats (TOCTOU race).
class BookingCapacityError extends Error {
constructor(public code: 'SOLD_OUT' | 'NOT_ENOUGH', public available?: number) {
super(code);
}
}
let createdTickets: any[] = [];
let createdPayments: any[] = [];
try {
if (isSqlite()) {
(db as any).transaction((tx: any) => {
const countRow = seatHolderCountQuery(tx, data.eventId).get();
const reserved = Number(countRow?.count || 0);
if (isEventSoldOut(event.capacity, reserved)) {
throw new BookingCapacityError('SOLD_OUT');
}
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
if (ticketCount > seatsLeft) {
throw new BookingCapacityError('NOT_ENOUGH', seatsLeft);
}
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,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: attendee.firstName,
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
attendeeEmail: data.email,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: rucNumber,
preferredLanguage: data.preferredLanguage || null,
status: 'pending',
qrCode,
checkinAt: null,
createdAt: now,
};
tx.insert(tickets).values(newTicket).run();
createdTickets.push(newTicket);
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,
};
tx.insert(payments).values(newPayment).run();
createdPayments.push(newPayment);
}
});
} else {
await (db as any).transaction(async (tx: any) => {
const countRow = await dbGet<any>(seatHolderCountQuery(tx, data.eventId));
const reserved = Number(countRow?.count || 0);
if (isEventSoldOut(event.capacity, reserved)) {
throw new BookingCapacityError('SOLD_OUT');
}
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
if (ticketCount > seatsLeft) {
throw new BookingCapacityError('NOT_ENOUGH', seatsLeft);
}
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,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: attendee.firstName,
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
attendeeEmail: data.email,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: rucNumber,
preferredLanguage: data.preferredLanguage || null,
status: 'pending',
qrCode,
checkinAt: null,
createdAt: now,
};
await tx.insert(tickets).values(newTicket);
createdTickets.push(newTicket);
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 tx.insert(payments).values(newPayment);
createdPayments.push(newPayment);
}
});
}
} catch (err: any) {
if (err instanceof BookingCapacityError) {
if (err.code === 'SOLD_OUT') {
return c.json({ error: 'Event is sold out' }, 400);
}
return c.json({
error: `Not enough seats available. Only ${err.available} spot(s) remaining.`,
}, 400);
}
throw err;
}
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 (skip for free events — confirm immediately)
let lnbitsInvoice = null;
const totalPrice = event.price * ticketCount;
// Free events: no payment step required — confirm tickets immediately
if (totalPrice === 0) {
for (const t of createdTickets) {
await (db as any)
.update(tickets)
.set({ status: 'confirmed', paymentStatus: 'paid' })
.where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending')));
await (db as any)
.update(payments)
.set({ status: 'paid', paidAt: now, updatedAt: now })
.where(and(eq((payments as any).ticketId, t.id), eq((payments as any).status, 'pending')));
}
emailService.sendBookingConfirmation(primaryTicket.id).catch(err => {
console.error('[Email] Failed to send free-booking confirmation:', err);
});
} else if (data.paymentMethod === 'lightning') {
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';
// Include the webhook secret (if configured) so the callback can be authenticated
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
const webhookUrl = webhookSecret
? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}`
: `${apiUrl}/api/lnbits/webhook`;
// 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,
expiry: LNBITS_INVOICE_EXPIRY_SECONDS, // 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,
},
});
const lnbitsExpiresAt = toDbDate(new Date(Date.now() + LNBITS_INVOICE_EXPIRY_SECONDS * 1000));
// Update primary payment with the LNbits invoice - the BOLT11 string and
// expiry are persisted so the "Pay now" page can redisplay this same
// invoice later instead of erroring out on an unpaid Lightning booking.
await (db as any)
.update(payments)
.set({
reference: lnbitsInvoice.paymentHash,
lnbitsInvoice: lnbitsInvoice.paymentRequest,
lnbitsExpiresAt,
lnbitsAmountSats: lnbitsInvoice.amount,
})
.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) => {
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
return {
id: ticket.id,
qrCode: ticket.qrCode,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
attendeeEmail: ticket.attendeeEmail,
event: {
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
startDatetime: event.startDatetime,
endDatetime: event.endDatetime,
location: event.location,
locationUrl: event.locationUrl,
},
timezone,
locale,
};
});
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 locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
const pdfBuffer = await generateTicketPDF({
id: ticket.id,
qrCode: ticket.qrCode,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
attendeeEmail: ticket.attendeeEmail,
event: {
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
startDatetime: event.startDatetime,
endDatetime: event.endDatetime,
location: event.location,
locationUrl: event.locationUrl,
},
timezone,
locale,
});
// 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 event check-in stats for scanner (lightweight endpoint for staff)
ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const eventId = c.req.query('eventId');
if (!eventId) {
return c.json({ error: 'eventId is required' }, 400);
}
// Get event info
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Count checked-in tickets
const checkedInCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, eventId),
eq((tickets as any).status, 'checked_in')
)
)
);
// Count confirmed + checked_in (total active)
const totalActiveCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
return c.json({
eventId,
capacity: event.capacity,
checkedIn: checkedInCount?.count || 0,
totalActive: totalActiveCount?.count || 0,
});
});
// Live search tickets (GET - for scanner live search)
ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const q = c.req.query('q')?.trim() || '';
const eventId = c.req.query('eventId');
if (q.length < 2) {
return c.json({ tickets: [] });
}
const searchTerm = `%${q.toLowerCase()}%`;
// Search by name (ILIKE), email (ILIKE), ticket ID (exact or partial)
const nameEmailConditions = [
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
// Ticket ID exact or partial match (cast UUID to text for LOWER)
sql`LOWER(CAST(${(tickets as any).id} AS TEXT)) LIKE ${searchTerm}`,
sql`LOWER(CAST(${(tickets as any).qrCode} AS TEXT)) LIKE ${searchTerm}`,
];
let whereClause: any = and(
or(...nameEmailConditions),
// Exclude cancelled tickets by default
sql`${(tickets as any).status} != 'cancelled'`
);
if (eventId) {
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
}
const matchingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(whereClause)
.limit(20)
);
// Enrich with event details
const results = await Promise.all(
matchingTickets.map(async (ticket: any) => {
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
);
return {
ticket_id: ticket.id,
name: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
email: ticket.attendeeEmail,
status: ticket.status,
checked_in: ticket.status === 'checked_in',
checkinAt: ticket.checkinAt,
event_id: ticket.eventId,
qrCode: ticket.qrCode,
event: event ? {
id: event.id,
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
} : null,
};
})
);
return c.json({ tickets: results });
});
// Get ticket by ID
// Capability-based access: the unguessable ticket UUID acts as the access token for
// guest bookings (no account required). For anonymous callers we withhold attendee PII
// (email/phone/RUC); the full record is only returned to the owner or admin/staff.
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))
);
// Count how many tickets belong to this booking (for per-quantity payment links)
let bookingTicketCount = 1;
if (ticket.bookingId) {
const bookingTickets = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
bookingTicketCount = bookingTickets.length || 1;
}
// Determine whether the requester is the owner or an admin/staff member
const authUser: any = await getAuthUser(c);
const isPrivileged = !!authUser && (
['admin', 'organizer', 'staff'].includes(authUser.role) || authUser.id === ticket.userId
);
const ticketPayload: any = { ...ticket, event, payment, bookingTicketCount };
if (!isPrivileged) {
// Strip attendee PII for anonymous capability-based access
delete ticketPayload.attendeeEmail;
delete ticketPayload.attendeePhone;
delete ticketPayload.attendeeRuc;
}
return c.json({ ticket: ticketPayload });
});
// 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 });
});
// Search tickets by name/email (for scanner manual search)
ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
const body = await c.req.json().catch(() => ({}));
const { query, eventId } = body;
if (!query || typeof query !== 'string' || query.trim().length < 2) {
return c.json({ error: 'Search query must be at least 2 characters' }, 400);
}
const searchTerm = `%${query.trim().toLowerCase()}%`;
const conditions = [
sql`LOWER(${(tickets as any).attendeeEmail}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeFirstName}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeLastName}) LIKE ${searchTerm}`,
sql`LOWER(${(tickets as any).attendeeFirstName} || ' ' || COALESCE(${(tickets as any).attendeeLastName}, '')) LIKE ${searchTerm}`,
];
let whereClause = or(...conditions);
if (eventId) {
whereClause = and(whereClause, eq((tickets as any).eventId, eventId));
}
const matchingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(whereClause)
.limit(20)
);
// Enrich with event details
const results = await Promise.all(
matchingTickets.map(async (ticket: any) => {
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
);
return {
id: ticket.id,
qrCode: ticket.qrCode,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
attendeeEmail: ticket.attendeeEmail,
attendeePhone: ticket.attendeePhone,
status: ticket.status,
checkinAt: ticket.checkinAt,
event: event ? {
id: event.id,
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
} : null,
};
})
);
return c.json({ tickets: results });
});
// 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,
paymentStatus: ticket.paymentStatus,
// Balance to collect at the door for unpaid tickets
amountDue: ticket.paymentStatus === 'unpaid' && event ? event.price : 0,
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);
}
// Confirmed/checked-in tickets can still be marked paid when they carry an
// unpaid balance (admin-added unpaid tickets collected at the door)
if (['confirmed', 'checked_in'].includes(ticket.status) && ticket.paymentStatus !== 'unpaid') {
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))
);
}
if (ticket.status === 'on_hold') {
// The seat was released when this booking went on hold - re-check capacity
// before confirming it directly.
try {
await reserveOnHoldBooking(
ticket.eventId,
ticketsToConfirm.map((t: any) => t.id),
'confirmed',
'paid',
{ paidByAdminId: user.id }
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'This event is now full. Your spot was released after the payment deadline passed.',
}, 400);
}
throw err;
}
} else {
// Confirm all tickets in the booking (checked-in tickets keep their status)
for (const t of ticketsToConfirm) {
// Update ticket status
await (db as any)
.update(tickets)
.set({ status: t.status === 'checked_in' ? 'checked_in' : 'confirmed', paymentStatus: 'paid' })
.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', rateLimitMiddleware({ max: 10, windowMs: 10 * 60 * 1000, prefix: '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,
});
}
// A booking that was auto-released after the hold threshold: recover it by
// re-reserving the seat(s) and moving back into the admin approval queue.
if (payment.status === 'on_hold') {
let ticketsToRecover: any[] = [ticket];
if (ticket.bookingId) {
ticketsToRecover = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
try {
await reserveOnHoldBooking(
ticket.eventId,
ticketsToRecover.map((t: any) => t.id),
'pending',
'pending_approval',
{ extraPaymentFields: { userMarkedPaidAt: getNow(), payerName: payerName?.trim() || null } }
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'This event is now full. Your spot was released after the payment deadline passed.',
}, 400);
}
throw err;
}
const recoveredPayment = await dbGet(
(db as any).select().from(payments).where(eq((payments as any).id, payment.id))
);
return c.json({
payment: recoveredPayment,
message: 'Payment marked as sent. Waiting for admin approval.',
});
}
// 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 for this ticket and any siblings
// in a multi-ticket booking (mirrors the approve flow's bookingId fan-out).
let ticketsToMark: any[] = [ticket];
if (ticket.bookingId) {
ticketsToMark = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
for (const t of ticketsToMark) {
await (db as any)
.update(payments)
.set({
status: 'pending_approval',
userMarkedPaidAt: now,
payerName: payerName?.trim() || null,
updatedAt: now,
})
.where(
and(
eq((payments as any).ticketId, (t as any).id),
eq((payments as any).status, 'pending')
)
);
}
// Get updated payment for the requested ticket
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 (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
const adminFullName = data.lastName && data.lastName.trim()
? `${data.firstName} ${data.lastName}`.trim()
: data.firstName;
if (!user) {
const userId = generateId();
user = {
id: userId,
email: accountEmail,
password: null,
name: adminFullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
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,
paymentStatus: 'paid',
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);
});
// Unified admin add-attendee endpoint backing the single Add Ticket modal.
// type drives payment handling:
// paid — email required; paid cash payment; confirmation email + QR sent
// door — paid in cash at the door; all fields optional; counts toward revenue;
// confirmation email only when an email is provided
// unpaid — QR issued with balance due (collect at door); pending tpago payment;
// pay-link (Bancard/TPago) email sent when an email is provided
// guest — free comp ticket, not counted in revenue; confirmation email only
// when an email is provided
ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
eventId: z.string(),
type: z.enum(['paid', 'door', 'unpaid', 'guest']),
// Door walk-ins can be logged with nothing filled in, so firstName is only
// required for the other types
firstName: z.string().optional().or(z.literal('')),
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(),
checkinNow: z.boolean().optional().default(false),
adminNote: z.string().max(1000).optional(),
}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), {
message: 'Email is required for paid tickets',
path: ['email'],
}).refine((d) => d.type === 'door' || !!(d.firstName && d.firstName.trim()), {
message: 'First name is required',
path: ['firstName'],
})), async (c) => {
const data = c.req.valid('json');
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-added tickets bypass the capacity check (intentional over-capacity)
const now = getNow();
const adminUser = (c as any).get('user');
const hasEmail = !!(data.email && data.email.trim());
const attendeeEmail = hasEmail
? data.email!.trim()
: `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`;
// Nameless door walk-ins still need a display name on the ticket
const firstName = (data.firstName && data.firstName.trim()) || 'Walk-in';
const fullName = data.lastName && data.lastName.trim()
? `${firstName} ${data.lastName.trim()}`
: firstName;
// Find or create user (see the note on `accountEmail` in the booking route)
const accountEmail = normalizeEmail(attendeeEmail);
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
);
if (!user) {
const userId = generateId();
user = {
id: userId,
email: accountEmail,
password: null,
name: fullName,
phone: data.phone || null,
role: 'user',
languagePreference: null,
isClaimed: toDbBool(false),
accountStatus: 'unclaimed',
emailVerified: false,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(user);
}
// Check for existing active ticket (only when a real email was provided)
if (hasEmail) {
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);
}
}
const ticketId = generateId();
const qrCode = generateTicketCode();
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'unpaid' ? 'unpaid' : 'paid';
const newTicket = {
id: ticketId,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: hasEmail ? data.email!.trim() : null,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
preferredLanguage: data.preferredLanguage || null,
status: data.checkinNow ? 'checked_in' : 'confirmed',
isGuest: data.type === 'guest' ? 1 : 0,
paymentStatus,
qrCode,
checkinAt: data.checkinNow ? now : null,
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
adminNote: data.adminNote || null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
// Payment record: paid cash for paid/door/guest ($0 for guest), pending tpago for unpaid
const paymentId = generateId();
const newPayment = data.type === 'unpaid'
? {
id: paymentId,
ticketId,
provider: 'tpago',
amount: event.price,
currency: event.currency,
status: 'pending',
reference: 'Unpaid ticket — collect at door',
paidAt: null,
paidByAdminId: null,
createdAt: now,
updatedAt: now,
}
: {
id: paymentId,
ticketId,
provider: 'cash',
amount: data.type === 'guest' ? 0 : event.price,
currency: event.currency,
status: 'paid',
reference: data.type === 'guest'
? 'Guest invite'
: data.type === 'door'
? 'Paid at door'
: 'Manual ticket',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
// Emails (asynchronous): paid always confirms; door/guest confirm only when an
// email exists; unpaid sends the TPago (Bancard) pay-link instructions instead
if (data.type === 'unpaid') {
if (hasEmail) {
emailService.sendPaymentInstructions(ticketId).then(result => {
if (!result.success) {
console.error(`[Email] Failed to send pay link for unpaid ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error('[Email] Exception sending pay link for unpaid ticket:', err);
});
}
} else if (data.type === 'paid' || hasEmail) {
emailService.sendBookingConfirmation(ticketId).then(result => {
if (!result.success) {
console.error(`[Email] Failed to send booking confirmation for ${data.type} ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error(`[Email] Exception sending booking confirmation for ${data.type} ticket:`, err);
});
}
const messages: Record<string, string> = {
paid: 'Ticket created — confirmation email sent',
door: hasEmail
? 'Ticket created — paid at the door, confirmation email sent'
: 'Ticket created — paid at the door',
unpaid: hasEmail
? 'Unpaid ticket created — payment link sent'
: 'Unpaid ticket created — collect payment at the door',
guest: hasEmail
? 'Guest invited — confirmation email sent'
: 'Guest invited',
};
return c.json({
ticket: {
...newTicket,
event: {
title: event.title,
startDatetime: event.startDatetime,
location: event.location,
},
},
payment: newPayment,
message: data.checkinNow ? `${messages[data.type]} · checked in` : messages[data.type],
}, 201);
});
// Get all tickets (admin) - includes payment for each ticket
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 ticketsList = await dbAll(query);
const ticketIds = ticketsList.map((t: any) => t.id);
let paymentByTicketId: Record<string, any> = {};
if (ticketIds.length > 0) {
const paymentsList = await dbAll(
(db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))
);
for (const p of paymentsList as any[]) {
paymentByTicketId[p.ticketId] = p;
}
}
const ticketsWithPayment = ticketsList.map((t: any) => ({
...t,
payment: paymentByTicketId[t.id] || null,
}));
return c.json({ tickets: ticketsWithPayment });
});
export default ticketsRouter;