import {NachklangFeedbackDB} from '../Feedback.db'; import {Song} from '../feedback.interface'; import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface'; const UMLAUT_MAP: Record = { 'ä': '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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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(existingRows.map((r: any) => r.question_id)); const nextIds = new Set(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(); } };