changemaker.lite/api/src/modules/ticketed-events/ticketed-events-public.routes.ts
bunker-admin 0c2ffe754e Harden Stripe payment integration: 15 security fixes from audit
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
2026-03-31 08:34:23 -06:00

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 };