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
@@ -0,0 +1,209 @@
import {NachklangFeedbackDB} from '../Feedback.db';
import {
AnswerRow, EventReport, FreeTextReport, SongPickReport, SongRatingReport
} from './reports.admin.interface';
const FREE_TEXT_CAP = 500;
/**
* Pure aggregation over one event's answer rows - no DB access, so it's
* directly unit-testable against fixture data. group key is questionId
* when present, falling back to the label snapshot for answers whose
* question was hard-deleted (question_id IS NULL).
*/
export const aggregateReport = (
eventMeta: {eventId: number; name: string; eventDate: string; feedbackDeadline: string},
submissionStats: {totalSubmissions: number; firstSubmissionAt: string | null; lastSubmissionAt: string | null},
answerRows: AnswerRow[],
guestBookCount: number,
newsletterCounts: {total: number; sent: number; pending: number; failed: number}
): EventReport => {
const groupKey = (row: AnswerRow) => `${row.questionId ?? 'null'}::${row.questionLabel}`;
const songPickGroups = new Map<string, AnswerRow[]>();
const songRatingGroups = new Map<string, AnswerRow[]>();
const freeTextGroups = new Map<string, AnswerRow[]>();
for (const row of answerRows) {
const key = groupKey(row);
const target = row.questionType === 'SONG_PICK' ? songPickGroups
: row.questionType === 'SONG_RATING' ? songRatingGroups
: freeTextGroups;
if (!target.has(key)) target.set(key, []);
target.get(key)!.push(row);
}
const songPicks: SongPickReport[] = [...songPickGroups.values()].map((rows) => {
const votesBySong = new Map<number, {title: string; votes: number}>();
for (const row of rows) {
if (row.songId === null || row.songTitle === null) continue;
const entry = votesBySong.get(row.songId) || {title: row.songTitle, votes: 0};
entry.votes += 1;
votesBySong.set(row.songId, entry);
}
const results = [...votesBySong.entries()]
.map(([songId, v]) => ({songId, title: v.title, votes: v.votes}))
.sort((a, b) => b.votes - a.votes);
return {
questionId: rows[0].questionId,
label: rows[0].questionLabel,
totalVotes: results.reduce((sum, r) => sum + r.votes, 0),
results
};
});
const songRatings: SongRatingReport[] = [...songRatingGroups.values()].map((rows) => {
const sumsBySong = new Map<number, {title: string; sum: number; count: number}>();
for (const row of rows) {
if (row.songId === null || row.songTitle === null || row.rating === null) continue;
const entry = sumsBySong.get(row.songId) || {title: row.songTitle, sum: 0, count: 0};
entry.sum += row.rating;
entry.count += 1;
sumsBySong.set(row.songId, entry);
}
const results = [...sumsBySong.entries()]
.map(([songId, v]) => ({songId, title: v.title, average: Math.round((v.sum / v.count) * 10) / 10, count: v.count}))
.sort((a, b) => b.average - a.average);
return {questionId: rows[0].questionId, label: rows[0].questionLabel, results};
});
const freeText: FreeTextReport[] = [...freeTextGroups.values()].map((rows) => {
const sorted = rows
.filter((row) => row.textAnswer !== null)
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime());
const responses = sorted.slice(0, FREE_TEXT_CAP).map((row) => ({
submissionId: row.submissionId,
submittedAt: row.submittedAt,
text: row.textAnswer!
}));
return {
questionId: rows[0].questionId,
label: rows[0].questionLabel,
responses,
hasMore: sorted.length > FREE_TEXT_CAP
};
});
return {
event: eventMeta,
totalSubmissions: submissionStats.totalSubmissions,
firstSubmissionAt: submissionStats.firstSubmissionAt,
lastSubmissionAt: submissionStats.lastSubmissionAt,
songPicks,
songRatings,
freeText,
guestBookCount,
newsletter: newsletterCounts
};
};
export const getReport = async (eventId: number): Promise<EventReport | null> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const eventRows = await conn.query('SELECT event_id, name, event_date, feedback_deadline FROM events WHERE event_id = ?', [eventId]);
if (eventRows.length === 0) return null;
const eventRow = eventRows[0];
const statsRows = await conn.query(
'SELECT COUNT(*) as cnt, MIN(submitted_at) as first_at, MAX(submitted_at) as last_at FROM submissions WHERE event_id = ?',
[eventId]
);
const stats = statsRows[0];
const answerRows = await conn.query(
`SELECT sa.submission_id, s.submitted_at, sa.question_id, sa.question_label_snapshot,
sa.question_type, sa.song_id, sa.song_title_snapshot, sa.rating, sa.text_answer
FROM submission_answers sa
INNER JOIN submissions s ON s.submission_id = sa.submission_id
WHERE sa.event_id = ?`,
[eventId]
);
const answers: AnswerRow[] = answerRows.map((r: any) => ({
submissionId: r.submission_id,
submittedAt: r.submitted_at,
questionId: r.question_id,
questionLabel: r.question_label_snapshot,
questionType: r.question_type,
songId: r.song_id,
songTitle: r.song_title_snapshot,
rating: r.rating,
textAnswer: r.text_answer
}));
const guestBookRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]);
const guestBookCount = Number(guestBookRows[0].cnt);
const newsletterRows = await conn.query(
`SELECT sync_status, COUNT(*) as cnt FROM newsletter_signups WHERE event_id = ? GROUP BY sync_status`,
[eventId]
);
const newsletterCounts = {total: 0, sent: 0, pending: 0, failed: 0};
for (const row of newsletterRows) {
const cnt = Number(row.cnt);
newsletterCounts.total += cnt;
if (row.sync_status === 'SENT') newsletterCounts.sent = cnt;
else if (row.sync_status === 'PENDING') newsletterCounts.pending = cnt;
else if (row.sync_status === 'FAILED') newsletterCounts.failed = cnt;
}
return aggregateReport(
{eventId: eventRow.event_id, name: eventRow.name, eventDate: eventRow.event_date, feedbackDeadline: eventRow.feedback_deadline},
{totalSubmissions: Number(stats.cnt), firstSubmissionAt: stats.first_at, lastSubmissionAt: stats.last_at},
answers,
guestBookCount,
newsletterCounts
);
} finally {
await conn.end();
}
};
export interface GuestBookEntry {
entryId: number;
submittedAt: string;
displayName: string | null;
message: string | null;
}
export const getGuestBookEntries = async (eventId: number, page: number, pageSize: number): Promise<{entries: GuestBookEntry[]; total: number}> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const totalRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]);
const rows = await conn.query(
'SELECT entry_id, created_at, display_name, message FROM guest_book_entries WHERE event_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?',
[eventId, pageSize, (page - 1) * pageSize]
);
return {
total: Number(totalRows[0].cnt),
entries: rows.map((r: any) => ({entryId: r.entry_id, submittedAt: r.created_at, displayName: r.display_name, message: r.message}))
};
} finally {
await conn.end();
}
};
export interface NewsletterSignupRow {
signupId: number;
firstName: string;
lastName: string;
email: string;
consentAt: string;
syncStatus: string;
lastError: string | null;
}
export const getNewsletterSignups = async (eventId: number): Promise<NewsletterSignupRow[]> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const rows = await conn.query(
'SELECT signup_id, first_name, last_name, email, consent_at, sync_status, last_error FROM newsletter_signups WHERE event_id = ? ORDER BY consent_at DESC',
[eventId]
);
return rows.map((r: any) => ({
signupId: r.signup_id, firstName: r.first_name, lastName: r.last_name, email: r.email,
consentAt: r.consent_at, syncStatus: r.sync_status, lastError: r.last_error
}));
} finally {
await conn.end();
}
};