Addresses 11 original findings (1 critical, 3 high, 4 medium, 3 low) plus 4 additional findings from security review: - Mask secrets in PUT /settings response (was leaking decrypted keys) - Add paymentCheckoutRateLimit (10/hr/IP) to all 5 checkout endpoints - Implement durable audit logging to payment_audit_log table - Pin Stripe API version to 2026-01-28.clover (SDK v20.3.1) - Add charge.dispute.created/closed webhook handlers with DISPUTED status - Restore tickets on dispute won, handle charge_refunded closure - Guard against sentinel passthrough corrupting stored Stripe keys - Wrap refund DB updates in try/catch with webhook reconciliation fallback - Add $transaction for product maxPurchases race condition - Remove dead Payment model lookup from handleChargeRefunded - Cap donation amount at $100k in both schemas - Add requirePaymentsEnabled middleware on all checkout routes - Remove Stripe internal IDs from CSV exports - Add Cache-Control: no-store on admin settings responses Bunker Admin
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
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<string, unknown>).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 };
|