Files
API/src/models/feedback/admin/events.admin.service.ts
T
Paddy 3ea9e630ed Migrate the API to native ESM and vitest; pin Node 26
Prep PR for the admin auth module (docs/plan-admin-auth.md step 1).
better-auth 1.7 ships ESM only, so the API moves off CommonJS:

- "type": "module", module nodenext, target ES2024, .js suffixes on all
  relative imports, require('mariadb'|'cors') replaced by imports, and
  export= packages (winston, app-root-path, bcrypt) consumed via default
  imports. The logger now uses appRoot.path explicitly.
- TypeScript 5.9, @types/node 26, tslint removed. Node 26 pinned via
  engines and .nvmrc (Plesk runs 26).
- Jest 28 + ts-jest replaced by vitest 5. Eight test files depend on
  hoisted module mocks with static imports and resetModules + require,
  which Jest's ESM mode does not support; vitest keeps them nearly
  verbatim. Coverage via @vitest/coverage-v8 (lcov), Sonar generic report
  via vitest-sonar-reporter, so sonar-project.properties is unchanged.
  vitest.config.ts sets FEEDBACK_IP_SALT so the suite passes without a
  local .env.
- dotenv 8 -> 16 and axios 0.24 -> 1.x: their old typings are not
  resolvable under nodenext.
- autoCommit: false dropped from the pool configs; it is not a mariadb
  connector option and was silently ignored.

tsc clean, 96/96 tests green, compiled app boots and serves /, /docs and
CORS under Node ESM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 16:02:26 +02:00

287 lines
10 KiB
TypeScript

import {NachklangFeedbackDB} from '../Feedback.db.js';
import {Song} from '../feedback.interface.js';
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface.js';
import {formatDatetime} from '../feedback.dates.js';
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,
posterImageUrl: row.poster_image_url,
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++;
}
};
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, poster_image_url, created_by_email)
VALUES (?,?,?,?,?,?,?,?) RETURNING event_id`;
const res = await conn.query(query, [
slug, input.name, input.subtitle || null, input.eventDate, formatDatetime(deadline),
input.introText || null, input.posterImageUrl || 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 (input.posterImageUrl !== undefined) { fields.push('poster_image_url = ?'); values.push(input.posterImageUrl || null); }
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();
}
};