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; skipped: number} ): EventReport => { const groupKey = (row: AnswerRow) => `${row.questionId ?? 'null'}::${row.questionLabel}`; const songPickGroups = new Map(); const songRatingGroups = new Map(); const freeTextGroups = new Map(); 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(); 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(); 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 => { 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, skipped: 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; else if (row.sync_status === 'SKIPPED') newsletterCounts.skipped = 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; submissionId: number; submittedAt: string; displayName: string | null; message: string | null; } // Escapes LIKE wildcards (% and _) so a search term is matched literally, // not interpreted as a pattern - a search for "50%" must not match everything. const escapeLikeTerm = (term: string) => term.replace(/[\\%_]/g, (c) => `\\${c}`); export const getGuestBookEntries = async ( eventId: number, page: number, pageSize: number, search?: string ): Promise<{entries: GuestBookEntry[]; total: number}> => { let conn = await NachklangFeedbackDB.getConnection(); try { const trimmedSearch = search?.trim(); const whereClause = trimmedSearch ? 'WHERE event_id = ? AND (display_name LIKE ? ESCAPE \'\\\\\' OR message LIKE ? ESCAPE \'\\\\\')' : 'WHERE event_id = ?'; const likeParam = trimmedSearch ? `%${escapeLikeTerm(trimmedSearch)}%` : undefined; const whereParams = trimmedSearch ? [eventId, likeParam, likeParam] : [eventId]; const totalRows = await conn.query(`SELECT COUNT(*) as cnt FROM guest_book_entries ${whereClause}`, whereParams); const rows = await conn.query( `SELECT entry_id, submission_id, created_at, display_name, message FROM guest_book_entries ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...whereParams, pageSize, (page - 1) * pageSize] ); return { total: Number(totalRows[0].cnt), entries: rows.map((r: any) => ({ entryId: r.entry_id, submissionId: r.submission_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; } /** * Deletes one submission and everything under it (its answers, guest book * entry, newsletter signup). Single-path deletes by submission_id - unlike * deleteEvent's multi-path cascade issue, there's only one way to reach each * child table here, so explicit ordering is for consistency with that * function's style, not to work around an ambiguous-cascade error. */ export const deleteSubmission = async (submissionId: number): Promise => { let conn = await NachklangFeedbackDB.getConnection(); try { await conn.beginTransaction(); const rows = await conn.query('SELECT submission_id FROM submissions WHERE submission_id = ?', [submissionId]); if (rows.length === 0) { await conn.rollback(); return false; } await conn.query('DELETE FROM guest_book_entries WHERE submission_id = ?', [submissionId]); await conn.query('DELETE FROM newsletter_signups WHERE submission_id = ?', [submissionId]); await conn.query('DELETE FROM submission_answers WHERE submission_id = ?', [submissionId]); await conn.query('DELETE FROM submissions WHERE submission_id = ?', [submissionId]); await conn.commit(); return true; } catch (err) { await conn.rollback(); throw err; } finally { await conn.end(); } }; export const getNewsletterSignups = async ( eventId: number, page: number, pageSize: number, search?: string ): Promise<{entries: NewsletterSignupRow[]; total: number}> => { let conn = await NachklangFeedbackDB.getConnection(); try { const trimmedSearch = search?.trim(); const whereClause = trimmedSearch ? 'WHERE event_id = ? AND (first_name LIKE ? ESCAPE \'\\\\\' OR last_name LIKE ? ESCAPE \'\\\\\' OR email LIKE ? ESCAPE \'\\\\\')' : 'WHERE event_id = ?'; const likeParam = trimmedSearch ? `%${escapeLikeTerm(trimmedSearch)}%` : undefined; const whereParams = trimmedSearch ? [eventId, likeParam, likeParam, likeParam] : [eventId]; const totalRows = await conn.query(`SELECT COUNT(*) as cnt FROM newsletter_signups ${whereClause}`, whereParams); const rows = await conn.query( `SELECT signup_id, first_name, last_name, email, consent_at, sync_status, last_error FROM newsletter_signups ${whereClause} ORDER BY consent_at DESC LIMIT ? OFFSET ?`, [...whereParams, pageSize, (page - 1) * pageSize] ); return { total: Number(totalRows[0].cnt), entries: 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(); } };