3ea9e630ed
Prep PR for the admin auth module (docs/plan-admin-auth.md step 1).
better-auth 1.7 ships ESM only, so the API moves off CommonJS:
- "type": "module", module nodenext, target ES2024, .js suffixes on all
relative imports, require('mariadb'|'cors') replaced by imports, and
export= packages (winston, app-root-path, bcrypt) consumed via default
imports. The logger now uses appRoot.path explicitly.
- TypeScript 5.9, @types/node 26, tslint removed. Node 26 pinned via
engines and .nvmrc (Plesk runs 26).
- Jest 28 + ts-jest replaced by vitest 5. Eight test files depend on
hoisted module mocks with static imports and resetModules + require,
which Jest's ESM mode does not support; vitest keeps them nearly
verbatim. Coverage via @vitest/coverage-v8 (lcov), Sonar generic report
via vitest-sonar-reporter, so sonar-project.properties is unchanged.
vitest.config.ts sets FEEDBACK_IP_SALT so the suite passes without a
local .env.
- dotenv 8 -> 16 and axios 0.24 -> 1.x: their old typings are not
resolvable under nodenext.
- autoCommit: false dropped from the pool configs; it is not a mariadb
connector option and was silently ignored.
tsc clean, 96/96 tests green, compiled app boots and serves /, /docs and
CORS under Node ESM.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.js';
|
|
import {sendServerError} from '../tickets.errors.js';
|
|
import {hashIp, redeemLimiter, validateLimiter} from '../tickets.ratelimit.js';
|
|
|
|
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});
|
|
}
|
|
});
|