Reviewed-on: #8 Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech> Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
This commit was merged in pull request #8.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
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});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user