3c4f3331d8
Jenkins Production Deployment
Reviewed-on: #8 Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech> Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
136 lines
4.9 KiB
TypeScript
136 lines
4.9 KiB
TypeScript
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});
|
|
}
|
|
});
|