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>
1786 lines
58 KiB
TypeScript
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;
|