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:
@@ -0,0 +1,109 @@
|
||||
import {aggregateReport} from '../../src/models/feedback/admin/reports.admin.service';
|
||||
import {AnswerRow} from '../../src/models/feedback/admin/reports.admin.interface';
|
||||
|
||||
const eventMeta = {eventId: 1, name: 'Sommerkonzert', eventDate: '2026-08-01', feedbackDeadline: '2026-08-15T23:59:59'};
|
||||
const emptyNewsletter = {total: 0, sent: 0, pending: 0, failed: 0};
|
||||
|
||||
const row = (overrides: Partial<AnswerRow>): AnswerRow => ({
|
||||
submissionId: 1,
|
||||
submittedAt: '2026-08-02T10:00:00.000Z',
|
||||
questionId: 1,
|
||||
questionLabel: 'Q',
|
||||
questionType: 'FREE_TEXT',
|
||||
songId: null,
|
||||
songTitle: null,
|
||||
rating: null,
|
||||
textAnswer: null,
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('aggregateReport - song picks', () => {
|
||||
it('counts votes per song and sorts by votes descending', () => {
|
||||
const answers: AnswerRow[] = [
|
||||
row({submissionId: 1, questionId: 5, questionLabel: 'Lieblingsstück?', questionType: 'SONG_PICK', songId: 10, songTitle: 'Abendlied'}),
|
||||
row({submissionId: 2, questionId: 5, questionLabel: 'Lieblingsstück?', questionType: 'SONG_PICK', songId: 10, songTitle: 'Abendlied'}),
|
||||
row({submissionId: 3, questionId: 5, questionLabel: 'Lieblingsstück?', questionType: 'SONG_PICK', songId: 11, songTitle: 'Morgenlied'})
|
||||
];
|
||||
const report = aggregateReport(eventMeta, {totalSubmissions: 3, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter);
|
||||
expect(report.songPicks).toHaveLength(1);
|
||||
expect(report.songPicks[0].totalVotes).toBe(3);
|
||||
expect(report.songPicks[0].results).toEqual([
|
||||
{songId: 10, title: 'Abendlied', votes: 2},
|
||||
{songId: 11, title: 'Morgenlied', votes: 1}
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps separate SONG_PICK questions in separate groups', () => {
|
||||
const answers: AnswerRow[] = [
|
||||
row({questionId: 5, questionLabel: 'Frage A', questionType: 'SONG_PICK', songId: 10, songTitle: 'Abendlied'}),
|
||||
row({questionId: 6, questionLabel: 'Frage B', questionType: 'SONG_PICK', songId: 11, songTitle: 'Morgenlied'})
|
||||
];
|
||||
const report = aggregateReport(eventMeta, {totalSubmissions: 2, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter);
|
||||
expect(report.songPicks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateReport - song ratings', () => {
|
||||
it('averages ratings per song, rounded to one decimal, sorted descending', () => {
|
||||
const answers: AnswerRow[] = [
|
||||
row({questionId: 7, questionLabel: 'Bewertung', questionType: 'SONG_RATING', songId: 10, songTitle: 'Abendlied', rating: 5}),
|
||||
row({questionId: 7, questionLabel: 'Bewertung', questionType: 'SONG_RATING', songId: 10, songTitle: 'Abendlied', rating: 4}),
|
||||
row({questionId: 7, questionLabel: 'Bewertung', questionType: 'SONG_RATING', songId: 11, songTitle: 'Morgenlied', rating: 3})
|
||||
];
|
||||
const report = aggregateReport(eventMeta, {totalSubmissions: 2, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter);
|
||||
expect(report.songRatings[0].results).toEqual([
|
||||
{songId: 10, title: 'Abendlied', average: 4.5, count: 2},
|
||||
{songId: 11, title: 'Morgenlied', average: 3, count: 1}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateReport - free text', () => {
|
||||
it('sorts newest first and caps at 500 with hasMore', () => {
|
||||
const answers: AnswerRow[] = Array.from({length: 501}, (_, i) =>
|
||||
row({
|
||||
submissionId: i,
|
||||
questionId: 9,
|
||||
questionLabel: 'Sonstiges',
|
||||
questionType: 'FREE_TEXT',
|
||||
textAnswer: `Antwort ${i}`,
|
||||
submittedAt: new Date(2026, 0, 1, 0, 0, i).toISOString()
|
||||
})
|
||||
);
|
||||
const report = aggregateReport(eventMeta, {totalSubmissions: 501, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter);
|
||||
expect(report.freeText[0].responses).toHaveLength(500);
|
||||
expect(report.freeText[0].hasMore).toBe(true);
|
||||
expect(report.freeText[0].responses[0].text).toBe('Antwort 500');
|
||||
});
|
||||
|
||||
it('does not set hasMore when at or under the cap', () => {
|
||||
const answers: AnswerRow[] = [row({questionId: 9, questionLabel: 'Sonstiges', questionType: 'FREE_TEXT', textAnswer: 'Danke!'})];
|
||||
const report = aggregateReport(eventMeta, {totalSubmissions: 1, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter);
|
||||
expect(report.freeText[0].hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateReport - top-level fields', () => {
|
||||
it('passes through submission stats, guest book count, and newsletter counts unchanged', () => {
|
||||
const report = aggregateReport(
|
||||
eventMeta,
|
||||
{totalSubmissions: 42, firstSubmissionAt: '2026-08-02T10:00:00.000Z', lastSubmissionAt: '2026-08-10T18:00:00.000Z'},
|
||||
[],
|
||||
7,
|
||||
{total: 10, sent: 6, pending: 2, failed: 2}
|
||||
);
|
||||
expect(report.totalSubmissions).toBe(42);
|
||||
expect(report.firstSubmissionAt).toBe('2026-08-02T10:00:00.000Z');
|
||||
expect(report.lastSubmissionAt).toBe('2026-08-10T18:00:00.000Z');
|
||||
expect(report.guestBookCount).toBe(7);
|
||||
expect(report.newsletter).toEqual({total: 10, sent: 6, pending: 2, failed: 2});
|
||||
expect(report.event).toEqual(eventMeta);
|
||||
});
|
||||
|
||||
it('produces empty arrays for an event with no submissions', () => {
|
||||
const report = aggregateReport(eventMeta, {totalSubmissions: 0, firstSubmissionAt: null, lastSubmissionAt: null}, [], 0, emptyNewsletter);
|
||||
expect(report.songPicks).toEqual([]);
|
||||
expect(report.songRatings).toEqual([]);
|
||||
expect(report.freeText).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user