17ca6399e0
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>
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
import {NachklangFeedbackDB} from '../Feedback.db';
|
|
import {Song} from '../feedback.interface';
|
|
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface';
|
|
|
|
const UMLAUT_MAP: Record<string, string> = {
|
|
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss',
|
|
'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue'
|
|
};
|
|
|
|
/**
|
|
* Slug base from a name: lowercase, umlaut-transliterated, hyphenated.
|
|
* The caller appends the concert year and resolves collisions.
|
|
*/
|
|
export const slugifyName = (name: string): string => {
|
|
const transliterated = name.replace(/[äöüßÄÖÜ]/g, (ch) => UMLAUT_MAP[ch] || ch);
|
|
return transliterated
|
|
.normalize('NFKD')
|
|
.replace(/[̀-ͯ]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
};
|
|
|
|
/**
|
|
* Default feedback deadline: event day + 14 days, end of day. Computed
|
|
* here (not by the DB) so the admin UI can pre-fill and override it.
|
|
*/
|
|
export const computeDefaultDeadline = (eventDateIso: string): Date => {
|
|
const [year, month, day] = eventDateIso.split('-').map(Number);
|
|
return new Date(year, month - 1, day + 14, 23, 59, 59);
|
|
};
|
|
|
|
const mapSummaryRow = (row: any): EventAdminSummary => ({
|
|
eventId: row.event_id,
|
|
slug: row.slug,
|
|
name: row.name,
|
|
subtitle: row.subtitle,
|
|
eventDate: row.event_date,
|
|
feedbackDeadline: row.feedback_deadline,
|
|
isPublished: !!row.is_published,
|
|
submissionCount: Number(row.submission_count)
|
|
});
|
|
|
|
export const listEventsAdmin = async (): Promise<EventAdminSummary[]> => {
|
|
let conn = await NachklangFeedbackDB.getConnection();
|
|
try {
|
|
const query = `
|
|
SELECT e.event_id, e.slug, e.name, e.subtitle, e.event_date, e.feedback_deadline, e.is_published,
|
|
COUNT(s.submission_id) as submission_count
|
|
FROM events e
|
|
LEFT JOIN submissions s ON s.event_id = e.event_id
|
|
GROUP BY e.event_id
|
|
ORDER BY e.event_date DESC`;
|
|
const rows = await conn.query(query);
|
|
return rows.map(mapSummaryRow);
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Slug base with the concert year appended as a disambiguator - unless the
|
|
* name already ends with it (e.g. "Adventskonzert 2026"), which would
|
|
* otherwise double up as "adventskonzert-2026-2026".
|
|
*/
|
|
export const slugBase = (name: string, eventDateIso: string): string => {
|
|
const year = eventDateIso.split('-')[0];
|
|
const nameSlug = slugifyName(name);
|
|
return nameSlug.endsWith(`-${year}`) ? nameSlug : `${nameSlug}-${year}`;
|
|
};
|
|
|
|
const generateUniqueSlug = async (conn: any, name: string, eventDate: string): Promise<string> => {
|
|
const base = slugBase(name, eventDate);
|
|
let candidate = base;
|
|
let suffix = 2;
|
|
// Small table, small admin audience - a loop is simpler and safer than
|
|
// a clever single query, and collisions will be rare in practice.
|
|
while (true) {
|
|
const rows = await conn.query('SELECT 1 FROM events WHERE slug = ?', [candidate]);
|
|
if (rows.length === 0) return candidate;
|
|
candidate = `${base}-${suffix}`;
|
|
suffix++;
|
|
}
|
|
};
|
|
|
|
const toMysqlDatetime = (d: Date): string => {
|
|
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())}`;
|
|
};
|
|
|
|
export const createEvent = async (input: CreateEventInput, createdByEmail: string): Promise<number> => {
|
|
let conn = await NachklangFeedbackDB.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
const slug = await generateUniqueSlug(conn, input.name, input.eventDate);
|
|
const deadline = input.feedbackDeadline
|
|
? new Date(input.feedbackDeadline)
|
|
: computeDefaultDeadline(input.eventDate);
|
|
|
|
const query = `
|
|
INSERT INTO events (slug, name, subtitle, event_date, feedback_deadline, intro_text, created_by_email)
|
|
VALUES (?,?,?,?,?,?,?) RETURNING event_id`;
|
|
const res = await conn.query(query, [
|
|
slug, input.name, input.subtitle || null, input.eventDate, toMysqlDatetime(deadline),
|
|
input.introText || null, createdByEmail
|
|
]);
|
|
await conn.commit();
|
|
return res[0].event_id;
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw err;
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|
|
|
|
export const getEventAdmin = async (eventId: number): Promise<EventAdminDetail | null> => {
|
|
let conn = await NachklangFeedbackDB.getConnection();
|
|
try {
|
|
const eventRows = await conn.query(`
|
|
SELECT e.*, COUNT(s.submission_id) as submission_count
|
|
FROM events e
|
|
LEFT JOIN submissions s ON s.event_id = e.event_id
|
|
WHERE e.event_id = ?
|
|
GROUP BY e.event_id`, [eventId]);
|
|
if (eventRows.length === 0) return null;
|
|
const row = eventRows[0];
|
|
|
|
const songRows = await conn.query('SELECT song_id, title, composer, position FROM songs WHERE event_id = ? ORDER BY position ASC', [eventId]);
|
|
const songs: Song[] = songRows.map((r: any) => ({songId: r.song_id, title: r.title, composer: r.composer, position: r.position}));
|
|
|
|
const questionRows = await conn.query(
|
|
'SELECT event_question_id, question_id, position, is_active FROM event_questions WHERE event_id = ? ORDER BY position ASC',
|
|
[eventId]
|
|
);
|
|
const questions: EventAdminQuestionAssignment[] = questionRows.map((r: any) => ({
|
|
eventQuestionId: r.event_question_id, questionId: r.question_id, position: r.position, isActive: !!r.is_active
|
|
}));
|
|
|
|
return {
|
|
...mapSummaryRow(row),
|
|
introText: row.intro_text,
|
|
songs,
|
|
questions
|
|
};
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|
|
|
|
export const updateEvent = async (eventId: number, input: UpdateEventInput): Promise<boolean> => {
|
|
const fields: string[] = [];
|
|
const values: any[] = [];
|
|
|
|
if (input.name !== undefined) { fields.push('name = ?'); values.push(input.name); }
|
|
if (input.subtitle !== undefined) { fields.push('subtitle = ?'); values.push(input.subtitle); }
|
|
if (input.eventDate !== undefined) { fields.push('event_date = ?'); values.push(input.eventDate); }
|
|
if (input.feedbackDeadline !== undefined) { fields.push('feedback_deadline = ?'); values.push(input.feedbackDeadline); }
|
|
if (input.isPublished !== undefined) { fields.push('is_published = ?'); values.push(input.isPublished ? 1 : 0); }
|
|
if (input.introText !== undefined) { fields.push('intro_text = ?'); values.push(input.introText); }
|
|
|
|
if (fields.length === 0) return true;
|
|
|
|
let conn = await NachklangFeedbackDB.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
values.push(eventId);
|
|
const res = await conn.query(`UPDATE events SET ${fields.join(', ')} WHERE event_id = ?`, values);
|
|
await conn.commit();
|
|
return res.affectedRows > 0;
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw err;
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|
|
|
|
export type DeleteEventResult = 'DELETED' | 'NOT_FOUND' | 'HAS_SUBMISSIONS';
|
|
|
|
/**
|
|
* Deletes an event and everything under it. Children are deleted in
|
|
* explicit dependency order rather than left to the DB's ON DELETE CASCADE
|
|
* chain: submission_answers and guest_book_entries are reachable from
|
|
* `events` via two different cascade paths (direct event_id FK, and via
|
|
* `submissions`/`songs`), and MariaDB can reject that as an ambiguous
|
|
* multi-path cascade. See IMPLEMENTATION_PLAN.md Phase 1 notes.
|
|
*/
|
|
export const deleteEvent = async (eventId: number, force: boolean): Promise<DeleteEventResult> => {
|
|
let conn = await NachklangFeedbackDB.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
|
|
const eventRows = await conn.query('SELECT event_id FROM events WHERE event_id = ?', [eventId]);
|
|
if (eventRows.length === 0) {
|
|
await conn.rollback();
|
|
return 'NOT_FOUND';
|
|
}
|
|
|
|
const countRows = await conn.query('SELECT COUNT(*) as cnt FROM submissions WHERE event_id = ?', [eventId]);
|
|
const submissionCount = Number(countRows[0].cnt);
|
|
if (submissionCount > 0 && !force) {
|
|
await conn.rollback();
|
|
return 'HAS_SUBMISSIONS';
|
|
}
|
|
|
|
await conn.query('DELETE FROM guest_book_entries WHERE event_id = ?', [eventId]);
|
|
await conn.query('DELETE FROM newsletter_signups WHERE event_id = ?', [eventId]);
|
|
await conn.query('DELETE FROM submission_answers WHERE event_id = ?', [eventId]);
|
|
await conn.query('DELETE FROM submissions WHERE event_id = ?', [eventId]);
|
|
await conn.query('DELETE FROM event_questions WHERE event_id = ?', [eventId]);
|
|
await conn.query('DELETE FROM songs WHERE event_id = ?', [eventId]);
|
|
await conn.query('DELETE FROM events WHERE event_id = ?', [eventId]);
|
|
|
|
await conn.commit();
|
|
return 'DELETED';
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw err;
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|
|
|
|
export const reorderSongs = async (eventId: number, songIds: number[]): Promise<void> => {
|
|
let conn = await NachklangFeedbackDB.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
for (let i = 0; i < songIds.length; i++) {
|
|
await conn.query('UPDATE songs SET position = ? WHERE song_id = ? AND event_id = ?', [i, songIds[i], eventId]);
|
|
}
|
|
await conn.commit();
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw err;
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|
|
|
|
export interface QuestionAssignmentItem {
|
|
questionId: number;
|
|
position: number;
|
|
isActive: boolean;
|
|
}
|
|
|
|
/**
|
|
* Bulk-sets an event's assigned questions in one transaction: inserts new
|
|
* assignments, updates existing ones' position/active state, and removes
|
|
* ones no longer present in `items`.
|
|
*/
|
|
export const setEventQuestions = async (eventId: number, items: QuestionAssignmentItem[]): Promise<void> => {
|
|
let conn = await NachklangFeedbackDB.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
|
|
const existingRows = await conn.query('SELECT question_id FROM event_questions WHERE event_id = ?', [eventId]);
|
|
const existingIds = new Set<number>(existingRows.map((r: any) => r.question_id));
|
|
const nextIds = new Set<number>(items.map((i) => i.questionId));
|
|
|
|
for (const existingId of existingIds) {
|
|
if (!nextIds.has(existingId)) {
|
|
await conn.query('DELETE FROM event_questions WHERE event_id = ? AND question_id = ?', [eventId, existingId]);
|
|
}
|
|
}
|
|
|
|
for (const item of items) {
|
|
if (existingIds.has(item.questionId)) {
|
|
await conn.query(
|
|
'UPDATE event_questions SET position = ?, is_active = ? WHERE event_id = ? AND question_id = ?',
|
|
[item.position, item.isActive ? 1 : 0, eventId, item.questionId]
|
|
);
|
|
} else {
|
|
await conn.query(
|
|
'INSERT INTO event_questions (event_id, question_id, position, is_active) VALUES (?,?,?,?)',
|
|
[eventId, item.questionId, item.position, item.isActive ? 1 : 0]
|
|
);
|
|
}
|
|
}
|
|
|
|
await conn.commit();
|
|
} catch (err) {
|
|
await conn.rollback();
|
|
throw err;
|
|
} finally {
|
|
await conn.end();
|
|
}
|
|
};
|