Add Feedback domain module: public submission flow, admin CRUD, reporting (#7)
Jenkins Production Deployment

Co-authored-by: Patrick Müller <mail@pmueller.me>
Reviewed-on: #7
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 #7.
This commit is contained in:
2026-08-23 09:39:02 +00:00
committed by Patrick Müller
parent e7621b8290
commit b05f6b9da0
38 changed files with 4115 additions and 2 deletions
@@ -0,0 +1,104 @@
import {NachklangFeedbackDB} from '../Feedback.db';
import {EventConfig, EventSummary, Question, Song} from '../feedback.interface';
/**
* Returns all events currently eligible to receive feedback:
* published, on or after their concert day, and before the deadline.
*/
export const getEligibleEvents = async (): Promise<EventSummary[]> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = `
SELECT slug, name, subtitle, event_date, feedback_deadline, poster_image_url
FROM events
WHERE is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()
ORDER BY event_date DESC`;
const rows = await conn.query(query);
return rows.map((row: any) => ({
slug: row.slug,
name: row.name,
subtitle: row.subtitle,
eventDate: row.event_date,
feedbackDeadline: row.feedback_deadline,
posterImageUrl: row.poster_image_url
}));
} finally {
await conn.end();
}
};
export type EventLookupResult =
| { status: 'OK'; eventId: number; event: EventConfig }
| { status: 'NOT_FOUND' }
| { status: 'CLOSED' };
/**
* Resolves a slug to its full public config: meta, ordered setlist, ordered
* active questions. Distinguishes "unknown slug" from "known but outside
* its feedback window" so callers can respond 404 vs 410. Also used
* internally by the submission flow, which additionally needs `eventId`.
*/
export const getEventConfigBySlug = async (slug: string): Promise<EventLookupResult> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const eventQuery = `
SELECT event_id, slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, is_published
FROM events WHERE slug = ?`;
const eventRows = await conn.query(eventQuery, [slug]);
if (eventRows.length === 0) {
return {status: 'NOT_FOUND'};
}
const eventRow = eventRows[0];
const eligibleQuery = `
SELECT 1 FROM events
WHERE event_id = ? AND is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()`;
const eligibleRows = await conn.query(eligibleQuery, [eventRow.event_id]);
if (eligibleRows.length === 0) {
return {status: 'CLOSED'};
}
const songsQuery = 'SELECT song_id, title, composer, position FROM songs WHERE event_id = ? ORDER BY position ASC';
const songRows = await conn.query(songsQuery, [eventRow.event_id]);
const songs: Song[] = songRows.map((row: any) => ({
songId: row.song_id,
title: row.title,
composer: row.composer,
position: row.position
}));
const questionsQuery = `
SELECT eq.event_question_id, eq.position, q.question_id, q.question_type, q.label, q.help_text
FROM event_questions eq
INNER JOIN questions q ON q.question_id = eq.question_id
WHERE eq.event_id = ? AND eq.is_active = 1
ORDER BY eq.position ASC`;
const questionRows = await conn.query(questionsQuery, [eventRow.event_id]);
const questions: Question[] = questionRows.map((row: any) => ({
eventQuestionId: row.event_question_id,
questionId: row.question_id,
type: row.question_type,
label: row.label,
helpText: row.help_text,
position: row.position
}));
return {
status: 'OK',
eventId: eventRow.event_id,
event: {
slug: eventRow.slug,
name: eventRow.name,
subtitle: eventRow.subtitle,
eventDate: eventRow.event_date,
feedbackDeadline: eventRow.feedback_deadline,
posterImageUrl: eventRow.poster_image_url,
introText: eventRow.intro_text,
songs,
questions
}
};
} finally {
await conn.end();
}
};
+193
View File
@@ -0,0 +1,193 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import logger from '../../../middleware/logger';
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service';
import {submitFeedback} from './submissions.service';
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit';
import {sendServerError} from '../feedback.errors';
/**
* 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) {
sendServerError(res, e);
}
});
/**
* @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) {
sendServerError(res, e);
}
});
/**
* @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, newsletterDropped: false});
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, newsletterDropped: result.newsletterDropped});
return;
}
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,98 @@
/**
* @swagger
* components:
* schemas:
* SubmissionRequest:
* type: object
* properties:
* answers:
* type: array
* items:
* type: object
* properties:
* eventQuestionId:
* type: integer
* example: 12
* songId:
* type: integer
* nullable: true
* description: SONG_PICK only
* ratings:
* type: array
* description: SONG_RATING only
* items:
* type: object
* properties:
* songId:
* type: integer
* rating:
* type: integer
* minimum: 1
* maximum: 5
* text:
* type: string
* nullable: true
* description: FREE_TEXT only
* guestBook:
* type: object
* nullable: true
* properties:
* displayName:
* type: string
* nullable: true
* message:
* type: string
* nullable: true
* newsletter:
* type: object
* nullable: true
* properties:
* firstName:
* type: string
* lastName:
* type: string
* email:
* type: string
* website:
* type: string
* description: Honeypot field. Must stay empty; a real visitor never fills it in.
* SubmissionResponse:
* type: object
* properties:
* submissionId:
* type: integer
* example: 91
* newsletterDropped:
* type: boolean
* description: True if the newsletter opt-in was present but failed validation (e.g. a malformed email) - the rest of the submission still saved.
*/
export interface RatingInput {
songId: number;
rating: number;
}
export interface AnswerInput {
eventQuestionId: number;
songId?: number;
ratings?: RatingInput[];
text?: string;
}
export interface GuestBookInput {
displayName?: string;
message?: string;
}
export interface NewsletterInput {
firstName: string;
lastName: string;
email: string;
}
export interface SubmissionRequestBody {
answers?: AnswerInput[];
guestBook?: GuestBookInput;
newsletter?: NewsletterInput;
website?: string;
}
@@ -0,0 +1,229 @@
import {NachklangFeedbackDB} from '../Feedback.db';
import {QuestionType} from '../feedback.interface';
import {getEventConfigBySlug} from './events.public.service';
import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface';
import {syncNewsletterSignup} from '../integrations/salesforce.service';
import logger from '../../../middleware/logger';
// Bump when the privacy/consent copy shown next to the newsletter opt-in
// changes; recorded per-signup so a past consent's exact wording is provable.
const CONSENT_TEXT_VERSION = '2026-08-02';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// A real setlist tops out around a few dozen songs and a handful of
// questions, so a legitimate submission never comes close to this. Caps
// total generated rows regardless of how large the client's answers/ratings
// arrays are, bounding the number of INSERTs one request can trigger.
export const MAX_ANSWER_ROWS = 200;
export interface ValidatedAnswerRow {
eventQuestionId: number;
questionId: number;
label: string;
type: QuestionType;
position: number;
songId: number | null;
songTitle: string | null;
rating: number | null;
text: string | null;
}
export interface ValidatedGuestBook {
displayName: string | null;
message: string | null;
}
export interface ValidatedNewsletter {
firstName: string;
lastName: string;
email: string;
}
/**
* Validates raw answers against the event's *actual* active questions and
* songs. Unknown eventQuestionId/songId are ignored rather than erroring —
* a stale tab must not lose someone's comment. Empty answers are dropped
* entirely; "no row" is the canonical representation of "skipped".
*/
export const validateAnswers = (
answers: AnswerInput[],
questionsById: Map<number, {eventQuestionId: number; questionId: number; type: QuestionType; label: string; position: number}>,
songTitleById: Map<number, string>
): ValidatedAnswerRow[] => {
const rows: ValidatedAnswerRow[] = [];
const pushRow = (row: ValidatedAnswerRow): boolean => {
if (rows.length >= MAX_ANSWER_ROWS) return false;
rows.push(row);
return true;
};
outer: for (const answer of answers) {
const question = questionsById.get(answer.eventQuestionId);
if (!question) continue;
if (question.type === 'SONG_PICK') {
if (answer.songId != null && songTitleById.has(answer.songId)) {
if (!pushRow({
eventQuestionId: question.eventQuestionId,
questionId: question.questionId,
label: question.label,
type: 'SONG_PICK',
position: question.position,
songId: answer.songId,
songTitle: songTitleById.get(answer.songId)!,
rating: null,
text: null
})) break outer;
}
} else if (question.type === 'SONG_RATING') {
// De-duplicate by songId (last value wins) before generating rows,
// so a client can't force one row per repeated entry for the same
// song by simply repeating it in the ratings array.
const ratingBySong = new Map<number, number>();
for (const r of answer.ratings || []) {
if (!songTitleById.has(r.songId)) continue;
ratingBySong.set(r.songId, Math.min(5, Math.max(1, Math.round(r.rating))));
}
for (const [songId, clamped] of ratingBySong) {
if (!pushRow({
eventQuestionId: question.eventQuestionId,
questionId: question.questionId,
label: question.label,
type: 'SONG_RATING',
position: question.position,
songId,
songTitle: songTitleById.get(songId)!,
rating: clamped,
text: null
})) break outer;
}
} else if (question.type === 'FREE_TEXT') {
const trimmed = (answer.text || '').trim();
if (trimmed.length > 0) {
if (!pushRow({
eventQuestionId: question.eventQuestionId,
questionId: question.questionId,
label: question.label,
type: 'FREE_TEXT',
position: question.position,
songId: null,
songTitle: null,
rating: null,
text: trimmed.slice(0, 5000)
})) break outer;
}
}
}
return rows;
};
export const validateGuestBook = (input?: GuestBookInput): ValidatedGuestBook | null => {
if (!input) return null;
const displayName = (input.displayName || '').trim().slice(0, 255) || null;
const message = (input.message || '').trim().slice(0, 2000) || null;
if (!displayName && !message) return null;
return {displayName, message};
};
export const validateNewsletter = (input?: NewsletterInput): ValidatedNewsletter | null => {
if (!input) return null;
const firstName = (input.firstName || '').trim().slice(0, 120);
const lastName = (input.lastName || '').trim().slice(0, 120);
const email = (input.email || '').trim().slice(0, 255);
if (!firstName || !lastName || !EMAIL_RE.test(email)) return null;
return {firstName, lastName, email};
};
export type SubmitResult =
| { status: 'OK'; submissionId: number; newsletterDropped: boolean }
| { status: 'NOT_FOUND' }
| { status: 'CLOSED' }
| { status: 'EMPTY' };
/**
* Validates and persists one feedback submission. Re-checks event
* eligibility (the window may have closed between page load and submit),
* validates every answer against the event's live questions/songs, then
* inserts everything in a single transaction.
*/
export const submitFeedback = async (slug: string, body: SubmissionRequestBody, ipHash: string | null): Promise<SubmitResult> => {
const lookup = await getEventConfigBySlug(slug);
if (lookup.status === 'NOT_FOUND') return {status: 'NOT_FOUND'};
if (lookup.status === 'CLOSED') return {status: 'CLOSED'};
const {eventId, event} = lookup;
const questionsById = new Map(event.questions.map(q => [q.eventQuestionId, q]));
const songTitleById = new Map(event.songs.map(s => [s.songId, s.title]));
const answerRows = validateAnswers(body.answers || [], questionsById, songTitleById);
const guestBook = validateGuestBook(body.guestBook);
const newsletter = validateNewsletter(body.newsletter);
// body.newsletter is only sent at all when the visitor had the opt-in
// checkbox on (see FeedbackForm.tsx), so a present-but-invalid block
// (e.g. a mistyped email) is distinguishable from "didn't opt in" - the
// rest of the submission still saves, but the client can tell the
// visitor their newsletter signup specifically didn't go through.
const newsletterDropped = !!body.newsletter && !newsletter;
if (answerRows.length === 0 && !guestBook && !newsletter) {
return {status: 'EMPTY'};
}
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const subQuery = 'INSERT INTO submissions (event_id, ip_hash, has_guestbook, has_newsletter) VALUES (?,?,?,?) RETURNING submission_id';
const subRes = await conn.query(subQuery, [eventId, ipHash, guestBook ? 1 : 0, newsletter ? 1 : 0]);
const submissionId = subRes[0].submission_id;
for (const row of answerRows) {
const ansQuery = `INSERT INTO submission_answers
(submission_id, event_id, question_id, event_question_id, question_label_snapshot, question_type, position_snapshot, song_id, song_title_snapshot, rating, text_answer)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`;
await conn.query(ansQuery, [
submissionId, eventId, row.questionId, row.eventQuestionId, row.label, row.type, row.position,
row.songId, row.songTitle, row.rating, row.text
]);
}
if (guestBook) {
const gbQuery = 'INSERT INTO guest_book_entries (submission_id, event_id, display_name, message) VALUES (?,?,?,?)';
await conn.query(gbQuery, [submissionId, eventId, guestBook.displayName, guestBook.message]);
}
let newsletterSignupId: number | null = null;
if (newsletter) {
// The signup is always persisted locally first, regardless of sync
// outcome - syncNewsletterSignup (fired after commit, below) is what
// actually talks to Salesforce and moves PENDING to SENT/FAILED.
const salesforceEnabled = process.env.SALESFORCE_ENABLED === 'true';
const nlQuery = `INSERT INTO newsletter_signups
(submission_id, event_id, first_name, last_name, email, consent_text_version, sync_status)
VALUES (?,?,?,?,?,?,?) RETURNING signup_id`;
const nlRes = await conn.query(nlQuery, [
submissionId, eventId, newsletter.firstName, newsletter.lastName, newsletter.email,
CONSENT_TEXT_VERSION, salesforceEnabled ? 'PENDING' : 'SKIPPED'
]);
newsletterSignupId = nlRes[0].signup_id;
}
await conn.commit();
if (newsletterSignupId !== null) {
const signupId = newsletterSignupId;
void syncNewsletterSignup(signupId).catch((err) => {
logger.error('syncNewsletterSignup threw outside its own error handling', {signupId, error: String(err)});
});
}
return {status: 'OK', submissionId, newsletterDropped};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};