import express, {Request, Response} from 'express'; import * as VoucherPublicService from './voucher.public.service'; import {sendServerError} from '../tickets.errors'; import {hashIp, redeemLimiter, validateLimiter} from '../tickets.ratelimit'; export const publicRouter = express.Router(); publicRouter.get('/', async (req: Request, res: Response) => { res.status(200).send('Nachklang e.V. Tickets API Endpoint'); }); const rateLimitGuard = (req: Request, res: Response, limiter: typeof validateLimiter): string | null => { const ipHash = hashIp(req.ip || ''); if (limiter.isRateLimited(ipHash)) { res.status(429).send({status: 'RATE_LIMITED', message: 'Zu viele Anfragen. Bitte versuche es später erneut.'}); return null; } return ipHash; }; /** * @swagger * /tickets/voucher/{code}: * get: * summary: Validate a voucher code * description: Returns status, prefill data, and eligible events (with deadline/capacity state) for a code. Rate-limited per IP. * tags: [tickets] * parameters: * - in: path * name: code * required: true * schema: * type: string * responses: * 200: * description: Success * content: * application/json: * schema: * $ref: '#/components/schemas/VoucherValidation' * 404: * description: Unknown code * 429: * description: Rate limited */ publicRouter.get('/voucher/:code', async (req: Request, res: Response) => { try { const ipHash = rateLimitGuard(req, res, validateLimiter); if (!ipHash) return; validateLimiter.recordRequest(ipHash); const voucher = await VoucherPublicService.validateVoucher(req.params.code.toUpperCase()); if (!voucher) { res.status(404).send({status: 'NOT_FOUND'}); return; } res.status(200).send(voucher); } catch (e: any) { sendServerError(res, e); } }); /** * @swagger * /tickets/voucher/{code}/redeem: * post: * summary: Redeem a voucher code * description: Marks the code redeemed, records the redemption, and sends a confirmation email with an .ics attachment. Rate-limited per IP. * tags: [tickets] * parameters: * - in: path * name: code * required: true * schema: * type: string * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/RedeemRequest' * responses: * 200: * description: Redeemed * 400: * description: Invalid request * 404: * description: Unknown code * 409: * description: Code already used, event no longer eligible, deadline passed, or capacity exceeded * 429: * description: Rate limited */ publicRouter.post('/voucher/:code/redeem', async (req: Request, res: Response) => { try { const ipHash = rateLimitGuard(req, res, redeemLimiter); if (!ipHash) return; redeemLimiter.recordRequest(ipHash); const code = req.params.code.toUpperCase(); const {eventId, contactName, contactEmail, contactAddress, guests} = req.body || {}; if (!eventId || !contactName || !contactEmail || !Array.isArray(guests)) { res.status(400).send({status: 'BAD_REQUEST', message: 'eventId, contactName, contactEmail, and guests are required'}); return; } const result = await VoucherPublicService.redeemVoucher(code, {eventId, contactName, contactEmail, contactAddress, guests}); switch (result.status) { case 'OK': res.status(200).send({status: 'OK', redemptionId: result.redemptionId}); return; case 'NOT_FOUND': res.status(404).send({status: 'NOT_FOUND'}); return; case 'ALREADY_USED': res.status(409).send({status: 'ALREADY_USED', message: 'Dieser Code wurde bereits eingelöst.'}); return; case 'INVALID_EVENT': res.status(409).send({status: 'INVALID_EVENT', message: 'Dieses Konzert ist für diesen Code nicht verfügbar.'}); return; case 'DEADLINE_PASSED': res.status(409).send({status: 'DEADLINE_PASSED', message: 'Die Anmeldefrist für dieses Konzert ist abgelaufen.'}); return; case 'CAPACITY_EXCEEDED': res.status(409).send({status: 'CAPACITY_EXCEEDED', message: 'Nicht genügend freie Plätze für dieses Konzert.', spotsRemaining: result.spotsRemaining}); return; case 'ADDRESS_REQUIRED': res.status(409).send({status: 'ADDRESS_REQUIRED', message: 'Für dieses Konzert ist eine Adresse erforderlich.'}); return; } } catch (e: any) { res.status(400).send({status: 'BAD_REQUEST', message: e.message}); } });