import { Router, Request, Response, NextFunction } from 'express'; import { validate } from '../../middleware/validate'; import { optionalAuth } from '../../middleware/auth.middleware'; import { ticketedEventsService } from './ticketed-events.service'; import { ticketsService } from './tickets.service'; import { ticketEmailService } from './ticket-email.service'; import { checkoutSchema, registerFreeSchema } from './ticketed-events.schemas'; import { getStripe } from '../../services/stripe.client'; import { prisma } from '../../config/database'; import { env } from '../../config/env'; import { AppError } from '../../middleware/error-handler'; import { paymentCheckoutRateLimit } from '../../middleware/rate-limit'; import { requirePaymentsEnabled } from '../payments/payment-settings.service'; const router = Router(); // GET / — list published events router.get('/', async (req: Request, res: Response, next: NextFunction) => { try { const page = parseInt(req.query.page as string) || 1; const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); const upcoming = req.query.upcoming === 'true'; const result = await ticketedEventsService.listPublished({ page, limit, upcoming }); res.json(result); } catch (err) { next(err); } }); // GET /my-tickets — authenticated user's tickets (must be before /:slug) router.get('/my-tickets', optionalAuth, async (req: Request, res: Response, next: NextFunction) => { try { if (!req.user) { throw new AppError(401, 'Authentication required', 'UNAUTHORIZED'); } const tickets = await ticketsService.getUserTickets(req.user.id); const ticketsWithQr = tickets.map(t => ({ ...t, qrUrl: `${env.API_URL}/api/qr?text=${encodeURIComponent(t.ticketCode)}&size=300`, })); res.json({ tickets: ticketsWithQr }); } catch (err) { next(err); } }); // GET /:slug — event detail by slug router.get('/:slug', async (req: Request, res: Response, next: NextFunction) => { try { const slug = req.params.slug as string; const event = await ticketedEventsService.findBySlug(slug); // Check private event access if (event.visibility === 'PRIVATE') { const inviteCode = req.query.inviteCode as string | undefined; if (!inviteCode || inviteCode !== event.inviteCode) { // Return limited info without invite code res.json({ id: event.id, slug: event.slug, title: event.title, visibility: 'PRIVATE', requiresInviteCode: true, }); return; } } // Only show published or completed events publicly if (event.status !== 'PUBLISHED' && event.status !== 'COMPLETED') { throw new AppError(404, 'Event not found', 'NOT_FOUND'); } // Include format + hasMeeting (but NOT the room name — that's gated) const { meeting, ...rest } = event; res.json({ ...rest, hasMeeting: !!(meeting?.isActive), }); } catch (err) { next(err); } }); // GET /:slug/meeting-access — ticket-gated Jitsi room access router.get('/:slug/meeting-access', async (req: Request, res: Response, next: NextFunction) => { try { const slug = req.params.slug as string; const ticketCode = req.query.ticketCode as string; if (!ticketCode) { throw new AppError(400, 'ticketCode query parameter is required', 'MISSING_TICKET_CODE'); } const access = await ticketedEventsService.getMeetingAccess(slug, ticketCode); res.json(access); } catch (err) { next(err); } }); // GET /:slug/availability — ticket availability router.get('/:slug/availability', async (req: Request, res: Response, next: NextFunction) => { try { const slug = req.params.slug as string; const event = await prisma.ticketedEvent.findUnique({ where: { slug } }); if (!event || event.status !== 'PUBLISHED') { throw new AppError(404, 'Event not found', 'NOT_FOUND'); } const availability = await ticketedEventsService.getAvailability(event.id); res.json(availability); } catch (err) { next(err); } }); // POST /:slug/checkout — create Stripe checkout for paid ticket router.post('/:slug/checkout', requirePaymentsEnabled, paymentCheckoutRateLimit, optionalAuth, validate(checkoutSchema), async (req: Request, res: Response, next: NextFunction) => { try { const slug = req.params.slug as string; const { tierId, quantity, buyerEmail, buyerName } = req.body; const event = await prisma.ticketedEvent.findUnique({ where: { slug } }); if (!event || event.status !== 'PUBLISHED') { throw new AppError(404, 'Event not found', 'NOT_FOUND'); } const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } }); if (!tier || tier.eventId !== event.id) { throw new AppError(400, 'Invalid tier', 'INVALID_TIER'); } if (tier.tierType === 'FREE') { throw new AppError(400, 'Use /register for free tickets', 'USE_REGISTER'); } // Check sales window const now = new Date(); if (tier.salesStartAt && tier.salesStartAt > now) { throw new AppError(400, 'Ticket sales have not started yet', 'NOT_ON_SALE'); } if (tier.salesEndAt && tier.salesEndAt < now) { throw new AppError(400, 'Ticket sales have ended', 'SALES_ENDED'); } // Atomically reserve capacity (soldCount + reservedCount checked together) // This prevents overselling from concurrent Stripe checkout sessions const reserved = await prisma.$transaction(async (tx) => { const currentTier = await tx.ticketTier.findUnique({ where: { id: tierId } }); if (!currentTier) throw new AppError(400, 'Tier not found', 'NOT_FOUND'); const effectiveSold = currentTier.soldCount + currentTier.reservedCount; if (currentTier.maxQuantity && effectiveSold + quantity > currentTier.maxQuantity) { throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT'); } const currentEvent = await tx.ticketedEvent.findUnique({ where: { id: event.id } }); if (currentEvent?.maxAttendees && currentEvent.currentAttendees + currentTier.reservedCount + quantity > currentEvent.maxAttendees) { throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT'); } await tx.ticketTier.update({ where: { id: tierId }, data: { reservedCount: { increment: quantity } }, }); return currentTier; }); const unitAmount = reserved.tierType === 'DONATION' ? Math.max(reserved.priceCAD, reserved.minDonationCAD || 0) : reserved.priceCAD; const stripe = await getStripe(); const session = await stripe.checkout.sessions.create({ mode: 'payment', line_items: [{ price_data: { currency: 'cad', product_data: { name: `${event.title} — ${tier.name}`, description: tier.description || `Ticket for ${event.title}`, }, unit_amount: unitAmount, }, quantity, }], customer_email: buyerEmail, success_url: `${env.ADMIN_URL}/event/${slug}/ticket/{CHECKOUT_SESSION_ID}?success=true`, cancel_url: `${env.ADMIN_URL}/event/${slug}`, metadata: { type: 'event_ticket', eventId: event.id, tierId: tier.id, quantity: String(quantity), buyerEmail, buyerName: buyerName || '', userId: req.user?.id || '', }, expires_after: 1800, // 30 minutes } as never); // Create pending order await prisma.order.create({ data: { userId: req.user?.id || null, amountCAD: unitAmount * quantity, status: 'PENDING', stripeCheckoutSessionId: session.id, type: 'event_ticket', buyerEmail, buyerName: buyerName || null, }, }); res.json({ sessionId: session.id, url: session.url }); } catch (err) { next(err); } }); // POST /:slug/register — register for free ticket (no Stripe) router.post('/:slug/register', optionalAuth, validate(registerFreeSchema), async (req: Request, res: Response, next: NextFunction) => { try { const slug = req.params.slug as string; const { tierId, quantity, holderEmail, holderName } = req.body; const event = await prisma.ticketedEvent.findUnique({ where: { slug } }); if (!event || event.status !== 'PUBLISHED') { throw new AppError(404, 'Event not found', 'NOT_FOUND'); } const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } }); if (!tier || tier.eventId !== event.id) { throw new AppError(400, 'Invalid tier', 'INVALID_TIER'); } if (tier.tierType !== 'FREE') { throw new AppError(400, 'This tier requires payment', 'REQUIRES_PAYMENT'); } const tickets = await ticketsService.createTickets({ eventId: event.id, tierId, quantity, holderEmail, holderName, userId: req.user?.id, }); // Send confirmation emails (fire-and-forget) for (const ticket of tickets) { ticketEmailService.sendTicketConfirmation({ holderEmail: ticket.holderEmail, holderName: ticket.holderName, ticketCode: ticket.ticketCode, token: (ticket as Record).token as string, eventTitle: event.title, eventDate: event.date, eventStartTime: event.startTime, eventEndTime: event.endTime, eventSlug: event.slug, venueName: event.venueName, venueAddress: event.venueAddress, tierName: tier.name, eventFormat: event.eventFormat, }).catch(() => {}); } res.status(201).json({ tickets: tickets.map(t => ({ id: t.id, ticketCode: t.ticketCode, holderEmail: t.holderEmail, holderName: t.holderName, status: t.status, })), }); } catch (err) { next(err); } }); // GET /:slug/ticket/:ticketCode — ticket confirmation page data // Requires authentication or matching holder email to prevent PII enumeration router.get('/:slug/ticket/:ticketCode', optionalAuth, async (req: Request, res: Response, next: NextFunction) => { try { const ticket = await ticketsService.findByCode(req.params.ticketCode as string); if (ticket.event.slug !== req.params.slug) { throw new AppError(404, 'Ticket not found', 'NOT_FOUND'); } // Only return full details if user is authenticated and is the ticket holder or an admin const isHolder = req.user && ( req.user.id === ticket.userId || req.user.email === ticket.holderEmail ); const userRoles = req.user?.roles || (req.user?.role ? [req.user.role] : []); const isAdmin = userRoles.some((r: string) => ['SUPER_ADMIN', 'EVENTS_ADMIN'].includes(r)); if (!isHolder && !isAdmin) { // Return minimal non-PII data for unauthenticated/unrelated users res.json({ ticketCode: ticket.ticketCode, status: ticket.status, event: { slug: ticket.event.slug, title: ticket.event.title, date: ticket.event.date, startTime: ticket.event.startTime, endTime: ticket.event.endTime, venueName: ticket.event.venueName, }, tier: ticket.tier, }); return; } // Full details for authenticated ticket holder or admin const qrUrl = `${env.API_URL}/api/qr?text=${encodeURIComponent(ticket.ticketCode)}&size=300`; res.json({ ...ticket, qrUrl }); } catch (err) { next(err); } }); export { router as ticketedEventsPublicRouter };