c2ddb11c4c
Three fixes from the earlier review, plus cleanup: - submissions.service.ts: a newsletter opt-in present but failing validation (e.g. malformed email) was silently dropped with no signal to the client - the rest of the submission saved, but the visitor had no way to know their newsletter signup didn't go through. Added newsletterDropped to the submit response so the frontend can tell them. - reports.admin.service.ts: the newsletter summary tracked total/sent/pending/failed but silently omitted SKIPPED (stub-mode) signups from any bucket - every current signup showed total>0 with every bucket reading 0, indistinguishable from "we don't know what happened". Added a skipped count. - Consolidated two things duplicated across the module: sendServerError (reimplemented ~11 times, three of those as identical local copies of the same function) into feedback.errors.ts, and formatDatetime/ toMysqlDatetime (the same local-time formatting logic under two names, in csv.service.ts and events.admin.service.ts respectively) into feedback.dates.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
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<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, 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<boolean> => {
|
|
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();
|
|
}
|
|
};
|