Add Feedback domain module: public submission flow, admin CRUD, reporting

New /feedback API domain backed by its own FEEDBACK_DB, mirroring the
Calendar domain's router -> service -> DB pool layering:

- Public endpoints (no auth): eligible-events listing, event config,
  submission with honeypot + rate limiting (in-memory + DB backstop).
- Admin endpoints (session-header auth, reusing Calendar's users/sessions
  via a swappable feedback.auth.ts boundary): events/songs/questions CRUD,
  bulk reorder/assignment, aggregated reporting, CSV export.
- Schema in sql/feedback/001_init.sql (8 tables), applied and verified
  against the real FEEDBACK_DB.
- 64 Jest tests covering validation, auth, rate limiting, CSV escaping,
  and report aggregation (pure functions, no DB needed).

Includes fixes from a security review: path traversal defense doesn't
apply here (that's the frontend proxy, separate repo), but the
rate-limiter cluster does - recordSubmission now counts every processed
request (not just successful ones), the in-memory Map evicts empty
entries instead of growing unbounded, FEEDBACK_IP_SALT is required at
boot instead of silently degrading to unsalted hashing, and submission
answer/rating arrays are capped and de-duplicated to bound insert
amplification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 23:32:22 +02:00
parent e7621b8290
commit 17ca6399e0
32 changed files with 3615 additions and 2 deletions
+211
View File
@@ -0,0 +1,211 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service';
import {submitFeedback} from './submissions.service';
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit';
/**
* Router Definition
*/
export const publicRouter = express.Router();
/**
* True if the honeypot field was filled in — a real visitor never types
* into it, since it's hidden with CSS only. Pulled out as a pure function
* so the short-circuit behaviour is unit-testable without a live DB.
*/
export const isHoneypotTriggered = (body: any): boolean => {
return typeof body?.website === 'string' && body.website.trim().length > 0;
};
/**
* @swagger
* /feedback/events:
* get:
* summary: List currently eligible events
* description: Returns events that are published, on or after their concert day, and before their feedback deadline. An empty array is a valid, expected response.
* tags:
* - feedback
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/EventSummary'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ProcessingError'
*/
publicRouter.get('/events', async (req: Request, res: Response) => {
try {
const events = await getEligibleEvents();
res.status(200).send(events);
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /feedback/events/{slug}:
* get:
* summary: Get the full public config for one event
* description: Returns event meta, ordered setlist, and ordered active questions. 404 if the slug is unknown, 410 if the event exists but is outside its feedback window.
* tags:
* - feedback
* parameters:
* - in: path
* name: slug
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/EventConfig'
* 404:
* description: Unknown slug
* 410:
* description: Event exists but feedback is closed
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ProcessingError'
*/
publicRouter.get('/events/:slug', async (req: Request, res: Response) => {
try {
const result = await getEventConfigBySlug(req.params.slug);
if (result.status === 'NOT_FOUND') {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
if (result.status === 'CLOSED') {
res.status(410).send({status: 'FEEDBACK_CLOSED'});
return;
}
res.status(200).send(result.event);
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /feedback/events/{slug}/submissions:
* post:
* summary: Submit feedback for an event
* description: Every field is optional; the only validation error the public form can produce is EMPTY_SUBMISSION (nothing was filled in). Rate-limited per IP hash and honeypot-checked.
* tags:
* - feedback
* parameters:
* - in: path
* name: slug
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SubmissionRequest'
* responses:
* 201:
* description: Submitted
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/SubmissionResponse'
* 400:
* description: Nothing was filled in
* 404:
* description: Unknown slug
* 410:
* description: Event exists but feedback is closed
* 429:
* description: Rate limited
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ProcessingError'
*/
publicRouter.post('/events/:slug/submissions', async (req: Request, res: Response) => {
try {
const body = req.body || {};
// Honeypot: a real visitor never fills this in. Fake success, persist
// nothing, stay silent about it having failed.
if (isHoneypotTriggered(body)) {
logger.info('Feedback honeypot triggered', {slug: req.params.slug});
res.status(201).send({submissionId: -1});
return;
}
const ipHash = hashIp(req.ip || '');
if (await isRateLimited(ipHash)) {
res.status(429).send({status: 'RATE_LIMITED'});
return;
}
// Count every request that reaches this point against the limit,
// regardless of outcome - an attacker sending EMPTY/NOT_FOUND/CLOSED
// requests still costs DB round-trips per attempt and must not get an
// unlimited number of free ones.
recordSubmission(ipHash);
const result = await submitFeedback(req.params.slug, body, ipHash);
switch (result.status) {
case 'NOT_FOUND':
res.status(404).send({status: 'NOT_FOUND'});
return;
case 'CLOSED':
res.status(410).send({status: 'FEEDBACK_CLOSED'});
return;
case 'EMPTY':
res.status(400).send({status: 'EMPTY_SUBMISSION'});
return;
case 'OK':
res.status(201).send({submissionId: result.submissionId});
return;
}
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});