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,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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user