Files
API/src/models/feedback/admin/csv.service.ts
T
Paddy 17ca6399e0 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>
2026-08-05 23:32:22 +02:00

79 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {NachklangFeedbackDB} from '../Feedback.db';
const CSV_SEPARATOR = ';';
const UTF8_BOM = '';
/**
* RFC 4180 field escaping for a `;`-separated CSV, plus a formula-injection
* guard: a field starting with = + - @ gets a leading apostrophe so
* German-locale Excel never evaluates it as a formula.
*/
export const escapeCsvField = (value: string | number | null | undefined): string => {
let str = value === null || value === undefined ? '' : String(value);
str = str.replace(/\r\n|\r|\n/g, ' ');
if (/^[=+\-@]/.test(str)) {
str = `'${str}`;
}
if (str.includes(CSV_SEPARATOR) || str.includes('"')) {
str = `"${str.replace(/"/g, '""')}"`;
}
return str;
};
/** mariadb returns DATETIME columns as JS Date objects - format explicitly,
* otherwise String(date) falls back to the verbose Date.toString() format. */
export const formatDatetime = (value: Date | string | null): string => {
if (!value) return '';
const d = value instanceof Date ? value : new Date(value);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const buildCsv = (headers: string[], rows: (string | number | null | undefined)[][]): string => {
const lines = [headers.map(escapeCsvField).join(CSV_SEPARATOR)];
for (const row of rows) {
lines.push(row.map(escapeCsvField).join(CSV_SEPARATOR));
}
return UTF8_BOM + lines.join('\r\n');
};
export const buildResponsesCsv = async (eventId: number): Promise<string> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const rows = await conn.query(
`SELECT sa.submission_id, s.submitted_at, sa.question_label_snapshot, sa.question_type,
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 = ?
ORDER BY sa.submission_id ASC`,
[eventId]
);
return buildCsv(
['submission_id', 'submitted_at', 'question_label', 'question_type', 'song_title', 'rating', 'text_answer'],
rows.map((r: any) => [r.submission_id, formatDatetime(r.submitted_at), r.question_label_snapshot, r.question_type, r.song_title_snapshot, r.rating, r.text_answer])
);
} finally {
await conn.end();
}
};
export const buildGuestBookCsv = async (eventId: number): Promise<string> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const rows = await conn.query(
'SELECT entry_id, created_at, display_name, message FROM guest_book_entries WHERE event_id = ? ORDER BY created_at ASC',
[eventId]
);
return buildCsv(
['entry_id', 'submitted_at', 'display_name', 'message'],
rows.map((r: any) => [r.entry_id, formatDatetime(r.created_at), r.display_name, r.message])
);
} finally {
await conn.end();
}
};