diff --git a/CLAUDE.md b/CLAUDE.md index 35b50aa..2d5ba39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ npx jest test/some.test.ts ## Architecture -Express.js REST API in TypeScript with a service-oriented layering. The sole domain is `Calendar`, which organises **events** and **users**. +Express.js REST API in TypeScript with a service-oriented layering. Domains: `Calendar` (events, users) and `Feedback` (concert feedback forms, mounted at `/feedback`, backed by its own `FEEDBACK_DB` — see `src/models/feedback/`, still scaffolding-only as of this writing). **Request path:** 1. `app.ts` mounts `Calendar.router.ts` at `/calendar` @@ -53,6 +53,13 @@ DB_HOST= DB_USER= DB_PASSWORD= CALENDAR_DB= +FEEDBACK_DB= +FEEDBACK_IP_SALT= +FEEDBACK_RATE_LIMIT_MAX= +FEEDBACK_RATE_LIMIT_WINDOW_MIN= +SALESFORCE_ENABLED= +SALESFORCE_API_URL= +SALESFORCE_API_TOKEN= EMAIL_HOST= EMAIL_USERNAME= EMAIL_PASSWORD= diff --git a/app.ts b/app.ts index 9ad8e74..0d70d99 100644 --- a/app.ts +++ b/app.ts @@ -7,6 +7,7 @@ import logger from './src/middleware/logger'; // Router imports import {calendarRouter} from './src/models/calendar/Calendar.router'; +import {feedbackRouter} from './src/models/feedback/Feedback.router'; let cors = require('cors'); @@ -23,19 +24,34 @@ const port: number = parseInt(process.env.PORT, 10); const app: express.Application = express(); const server: http.Server = http.createServer(app); +// Behind Plesk's nginx, req.ip is the proxy unless we trust the forwarded header. +// Verify the resolved client IP is correct in staging before relying on it +// (used by the feedback rate limiter). +app.set('trust proxy', 1); + // here we are adding middleware to parse all incoming requests as JSON app.use(express.json()); // Configure CORS let allowedHosts = [ 'https://www.nachklang.art', - 'https://calendar.nachklang.art' + 'https://calendar.nachklang.art', + 'https://feedback.nachklang.art' ]; +const isDev = process.env.NODE_ENV !== 'production'; +const localhostRegex = /^http:\/\/localhost:\d+$/; app.use(cors({ + allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'], origin: function (origin: any, callback: any) { // Allow requests with no origin if (!origin) return callback(null, true); + // Any localhost port is fine outside production - dev servers pick + // whatever port is free (Next.js falls back from 3000 if it's taken). + if (isDev && localhostRegex.test(origin)) { + return callback(null, true); + } + // Block requests with wrong origin if (allowedHosts.indexOf(origin) === -1) { return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false); @@ -82,6 +98,7 @@ app.use( // Add routers app.use('/calendar', calendarRouter); +app.use('/feedback', feedbackRouter); // this is a simple route to make sure everything is working properly app.get('/', (req: express.Request, res: express.Response) => { diff --git a/sql/feedback/001_init.sql b/sql/feedback/001_init.sql new file mode 100644 index 0000000..4fd61cc --- /dev/null +++ b/sql/feedback/001_init.sql @@ -0,0 +1,142 @@ +-- Nachklang e.V. Feedback module — initial schema for FEEDBACK_DB +-- Apply manually against the FEEDBACK_DB database (separate from CALENDAR_DB). +-- See nachklang-feedback/IMPLEMENTATION_PLAN.md §2 for the full rationale +-- behind every design decision below (snapshot columns, denormalisation, +-- absence-over-sentinels, hashed IPs only). +-- +-- Apply with e.g.: +-- mysql -h -u -p < 001_init.sql + +USE `nachklang-feedback`; + +-- 1. events ------------------------------------------------------------- +CREATE TABLE events ( + event_id INT AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(80) NOT NULL, + name VARCHAR(255) NOT NULL, + subtitle VARCHAR(255) NULL, + event_date DATE NOT NULL, + feedback_deadline DATETIME NOT NULL, + is_published TINYINT(1) NOT NULL DEFAULT 0, + intro_text TEXT NULL, + created_by_email VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_events_slug (slug), + KEY idx_events_eligibility (is_published, event_date, feedback_deadline) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. songs (per-event setlist) ------------------------------------------ +CREATE TABLE songs ( + song_id INT AUTO_INCREMENT PRIMARY KEY, + event_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + composer VARCHAR(255) NULL, + position INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_songs_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE, + KEY idx_songs_event_position (event_id, position) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 3. questions (global reusable library) --------------------------------- +CREATE TABLE questions ( + question_id INT AUTO_INCREMENT PRIMARY KEY, + label VARCHAR(500) NOT NULL, + help_text VARCHAR(500) NULL, + question_type ENUM('SONG_PICK','SONG_RATING','FREE_TEXT') NOT NULL, + is_archived TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_questions_archived_type (is_archived, question_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 4. event_questions (join + ordering) ----------------------------------- +CREATE TABLE event_questions ( + event_question_id INT AUTO_INCREMENT PRIMARY KEY, + event_id INT NOT NULL, + question_id INT NOT NULL, + position INT NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_eq_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE, + CONSTRAINT fk_eq_question FOREIGN KEY (question_id) REFERENCES questions(question_id) ON DELETE RESTRICT, + UNIQUE KEY uq_eq_event_question (event_id, question_id), + KEY idx_eq_event_position (event_id, position, is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 5. submissions ---------------------------------------------------------- +CREATE TABLE submissions ( + submission_id INT AUTO_INCREMENT PRIMARY KEY, + event_id INT NOT NULL, + submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + ip_hash CHAR(64) NULL, + has_guestbook TINYINT(1) NOT NULL DEFAULT 0, + has_newsletter TINYINT(1) NOT NULL DEFAULT 0, + CONSTRAINT fk_sub_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE, + KEY idx_sub_event_time (event_id, submitted_at), + KEY idx_sub_iphash_time (ip_hash, submitted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 6. submission_answers ---------------------------------------------------- +-- Deliberate denormalisation: question label/type and song title are +-- snapshotted at submission time so later edits to the question library +-- never retroactively change what a past submission means. +CREATE TABLE submission_answers ( + answer_id INT AUTO_INCREMENT PRIMARY KEY, + submission_id INT NOT NULL, + event_id INT NOT NULL, + question_id INT NULL, + event_question_id INT NULL, + question_label_snapshot VARCHAR(500) NOT NULL, + question_type ENUM('SONG_PICK','SONG_RATING','FREE_TEXT') NOT NULL, + position_snapshot INT NOT NULL DEFAULT 0, + song_id INT NULL, + song_title_snapshot VARCHAR(255) NULL, + rating TINYINT NULL, + text_answer TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_ans_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE, + CONSTRAINT fk_ans_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE, + CONSTRAINT fk_ans_question FOREIGN KEY (question_id) REFERENCES questions(question_id) ON DELETE SET NULL, + CONSTRAINT fk_ans_song FOREIGN KEY (song_id) REFERENCES songs(song_id) ON DELETE SET NULL, + CONSTRAINT chk_ans_rating CHECK (rating IS NULL OR (rating BETWEEN 1 AND 5)), + KEY idx_ans_submission (submission_id), + KEY idx_ans_report (event_id, question_id, song_id), + KEY idx_ans_type (event_id, question_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 7. guest_book_entries ----------------------------------------------------- +-- Private, admin-only. No public wall in v1. +CREATE TABLE guest_book_entries ( + entry_id INT AUTO_INCREMENT PRIMARY KEY, + submission_id INT NOT NULL, + event_id INT NOT NULL, + display_name VARCHAR(255) NULL, + message TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_gb_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE, + CONSTRAINT fk_gb_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE, + KEY idx_gb_event_time (event_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 8. newsletter_signups ----------------------------------------------------- +CREATE TABLE newsletter_signups ( + signup_id INT AUTO_INCREMENT PRIMARY KEY, + submission_id INT NOT NULL, + event_id INT NOT NULL, + first_name VARCHAR(120) NOT NULL, + last_name VARCHAR(120) NOT NULL, + email VARCHAR(255) NOT NULL, + consent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + consent_text_version VARCHAR(40) NOT NULL, + sync_status ENUM('PENDING','SENT','FAILED','SKIPPED') NOT NULL DEFAULT 'PENDING', + sync_attempts INT NOT NULL DEFAULT 0, + synced_at DATETIME NULL, + external_id VARCHAR(120) NULL, + last_error TEXT NULL, + CONSTRAINT fk_nl_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE, + CONSTRAINT fk_nl_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE, + KEY idx_nl_sync_status (sync_status), + KEY idx_nl_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/models/feedback/Feedback.db.ts b/src/models/feedback/Feedback.db.ts new file mode 100644 index 0000000..b6dc9ed --- /dev/null +++ b/src/models/feedback/Feedback.db.ts @@ -0,0 +1,20 @@ +import * as dotenv from 'dotenv'; + +const mariadb = require('mariadb'); + +dotenv.config(); + +export namespace NachklangFeedbackDB { + const pool = mariadb.createPool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.FEEDBACK_DB, + connectionLimit: 5, + autoCommit: false + }); + + export const getConnection = async () => { + return pool.getConnection(); + }; +} diff --git a/src/models/feedback/Feedback.router.ts b/src/models/feedback/Feedback.router.ts new file mode 100644 index 0000000..217cca4 --- /dev/null +++ b/src/models/feedback/Feedback.router.ts @@ -0,0 +1,63 @@ +/** + * Required External Modules and Interfaces + */ +import express, {Request, Response} from 'express'; +import {Guid} from 'guid-typescript'; +import logger from '../../middleware/logger'; +import {publicRouter} from './public/public.router'; +import {adminRouter} from './admin/admin.router'; + +/** + * Router Definition + */ +export const feedbackRouter = express.Router(); + +feedbackRouter.use('/admin', adminRouter); +feedbackRouter.use('/', publicRouter); + +/** + * @swagger + * /feedback: + * get: + * summary: Feedback API root endpoint + * description: Returns a welcome message for the Nachklang e.V. Feedback API. + * tags: + * - feedback + * responses: + * 200: + * description: Success + * content: + * text/plain: + * schema: + * type: string + * example: Nachklang e.V. Feedback API Endpoint + * 500: + * description: Server error + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: PROCESSING_ERROR + * message: + * type: string + * example: Internal Server Error. Try again later. + * reference: + * type: string + * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 + */ +feedbackRouter.get('/', async (req: Request, res: Response) => { + try { + res.status(200).send('Nachklang e.V. Feedback API Endpoint'); + } catch (e: any) { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); + } +}); diff --git a/src/models/feedback/admin/admin.interface.ts b/src/models/feedback/admin/admin.interface.ts new file mode 100644 index 0000000..8326f5c --- /dev/null +++ b/src/models/feedback/admin/admin.interface.ts @@ -0,0 +1,130 @@ +/** + * @swagger + * components: + * parameters: + * SessionIdHeader: + * in: header + * name: X-Session-Id + * required: true + * schema: + * type: string + * SessionKeyHeader: + * in: header + * name: X-Session-Key + * required: true + * schema: + * type: string + * schemas: + * EventAdminSummary: + * type: object + * properties: + * eventId: + * type: integer + * slug: + * type: string + * name: + * type: string + * subtitle: + * type: string + * nullable: true + * eventDate: + * type: string + * format: date + * feedbackDeadline: + * type: string + * format: date-time + * isPublished: + * type: boolean + * submissionCount: + * type: integer + * EventAdminDetail: + * allOf: + * - $ref: '#/components/schemas/EventAdminSummary' + * - type: object + * properties: + * introText: + * type: string + * nullable: true + * songs: + * type: array + * items: + * $ref: '#/components/schemas/Song' + * questions: + * type: array + * items: + * type: object + * properties: + * eventQuestionId: + * type: integer + * questionId: + * type: integer + * position: + * type: integer + * isActive: + * type: boolean + * AdminQuestion: + * type: object + * properties: + * questionId: + * type: integer + * label: + * type: string + * helpText: + * type: string + * nullable: true + * questionType: + * $ref: '#/components/schemas/QuestionType' + * isArchived: + * type: boolean + */ + +import {QuestionType, Song} from '../feedback.interface'; + +export interface EventAdminSummary { + eventId: number; + slug: string; + name: string; + subtitle: string | null; + eventDate: string; + feedbackDeadline: string; + isPublished: boolean; + submissionCount: number; +} + +export interface EventAdminQuestionAssignment { + eventQuestionId: number; + questionId: number; + position: number; + isActive: boolean; +} + +export interface EventAdminDetail extends EventAdminSummary { + introText: string | null; + songs: Song[]; + questions: EventAdminQuestionAssignment[]; +} + +export interface CreateEventInput { + name: string; + subtitle?: string; + eventDate: string; + feedbackDeadline?: string; + introText?: string; +} + +export interface UpdateEventInput { + name?: string; + subtitle?: string; + eventDate?: string; + feedbackDeadline?: string; + isPublished?: boolean; + introText?: string; +} + +export interface AdminQuestion { + questionId: number; + label: string; + helpText: string | null; + questionType: QuestionType; + isArchived: boolean; +} diff --git a/src/models/feedback/admin/admin.router.ts b/src/models/feedback/admin/admin.router.ts new file mode 100644 index 0000000..e0f7977 --- /dev/null +++ b/src/models/feedback/admin/admin.router.ts @@ -0,0 +1,52 @@ +/** + * Required External Modules and Interfaces + */ +import express, {Request, Response} from 'express'; +import {requireAdminAuth} from '../feedback.auth'; +import {eventsAdminRouter} from './events.admin.router'; +import {songsAdminRouter} from './songs.admin.router'; +import {questionsAdminRouter} from './questions.admin.router'; +import {reportsAdminRouter} from './reports.admin.router'; + +/** + * Router Definition + */ +export const adminRouter = express.Router(); + +// Applied once at the top of the admin router tree - every route below +// requires a valid admin session. +adminRouter.use(requireAdminAuth); + +/** + * @swagger + * /feedback/admin/me: + * get: + * summary: Validate the current admin session + * description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * responses: + * 200: + * description: Success + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * fullName: + * type: string + * 401: + * description: Unauthorized + */ +adminRouter.get('/me', (req: Request, res: Response) => { + res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName}); +}); + +adminRouter.use('/events', eventsAdminRouter); +adminRouter.use('/events', reportsAdminRouter); +adminRouter.use('/songs', songsAdminRouter); +adminRouter.use('/questions', questionsAdminRouter); diff --git a/src/models/feedback/admin/csv.service.ts b/src/models/feedback/admin/csv.service.ts new file mode 100644 index 0000000..0aa91f1 --- /dev/null +++ b/src/models/feedback/admin/csv.service.ts @@ -0,0 +1,78 @@ +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 => { + 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 => { + 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(); + } +}; diff --git a/src/models/feedback/admin/events.admin.router.ts b/src/models/feedback/admin/events.admin.router.ts new file mode 100644 index 0000000..e6c075e --- /dev/null +++ b/src/models/feedback/admin/events.admin.router.ts @@ -0,0 +1,417 @@ +/** + * Required External Modules and Interfaces + */ +import express, {Request, Response} from 'express'; +import {Guid} from 'guid-typescript'; +import logger from '../../../middleware/logger'; +import * as EventsAdminService from './events.admin.service'; +import * as SongsAdminService from './songs.admin.service'; + +/** + * Router Definition + */ +export const eventsAdminRouter = express.Router(); + +const sendServerError = (res: Response, e: any) => { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); +}; + +/** + * @swagger + * /feedback/admin/events: + * get: + * summary: List all events (admin) + * description: All events, published or not, past or future, with submission counts. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * responses: + * 200: + * description: Success + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/EventAdminSummary' + * 401: + * description: Unauthorized + * post: + * summary: Create an event + * description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name, eventDate] + * properties: + * name: + * type: string + * subtitle: + * type: string + * eventDate: + * type: string + * format: date + * feedbackDeadline: + * type: string + * format: date-time + * introText: + * type: string + * responses: + * 201: + * description: Created + * 400: + * description: Missing required fields + * 401: + * description: Unauthorized + */ +eventsAdminRouter.get('/', async (req: Request, res: Response) => { + try { + res.status(200).send(await EventsAdminService.listEventsAdmin()); + } catch (e: any) { + sendServerError(res, e); + } +}); + +eventsAdminRouter.post('/', async (req: Request, res: Response) => { + try { + const {name, subtitle, eventDate, feedbackDeadline, introText} = req.body || {}; + if (!name || !eventDate) { + res.status(400).send({status: 'BAD_REQUEST', message: 'name and eventDate are required'}); + return; + } + const eventId = await EventsAdminService.createEvent( + {name, subtitle, eventDate, feedbackDeadline, introText}, + res.locals.admin.email + ); + res.status(201).send({eventId}); + } catch (e: any) { + sendServerError(res, e); + } +}); + +/** + * @swagger + * /feedback/admin/events/{eventId}: + * get: + * summary: Get one event (admin) + * description: Full event detail including setlist and assigned questions. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Success + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/EventAdminDetail' + * 404: + * description: Unknown event + * 401: + * description: Unauthorized + * put: + * summary: Update an event + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Updated + * 404: + * description: Unknown event + * 401: + * description: Unauthorized + * delete: + * summary: Delete an event + * description: Refuses with 409 if submissions exist unless ?force=true is passed. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * - in: query + * name: force + * schema: + * type: boolean + * responses: + * 204: + * description: Deleted + * 404: + * description: Unknown event + * 409: + * description: Submissions exist and force was not set + * 401: + * description: Unauthorized + */ +eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => { + try { + const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId)); + if (!event) { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + res.status(200).send(event); + } catch (e: any) { + sendServerError(res, e); + } +}); + +eventsAdminRouter.put('/:eventId', async (req: Request, res: Response) => { + try { + const updated = await EventsAdminService.updateEvent(Number(req.params.eventId), req.body || {}); + if (!updated) { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + res.status(200).send({status: 'OK'}); + } catch (e: any) { + sendServerError(res, e); + } +}); + +eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => { + try { + const force = req.query.force === 'true'; + const result = await EventsAdminService.deleteEvent(Number(req.params.eventId), force); + if (result === 'NOT_FOUND') { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + if (result === 'HAS_SUBMISSIONS') { + res.status(409).send({status: 'HAS_SUBMISSIONS', message: 'This event has submissions. Pass ?force=true to delete anyway.'}); + return; + } + res.status(204).send(); + } catch (e: any) { + sendServerError(res, e); + } +}); + +/** + * @swagger + * /feedback/admin/events/{eventId}/songs: + * get: + * summary: Get an event's setlist + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Success + * 401: + * description: Unauthorized + * post: + * summary: Add a song to an event's setlist + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [title] + * properties: + * title: + * type: string + * composer: + * type: string + * responses: + * 201: + * description: Created + * 400: + * description: Missing title + * 401: + * description: Unauthorized + */ +eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => { + try { + const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId)); + if (!event) { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + res.status(200).send(event.songs); + } catch (e: any) { + sendServerError(res, e); + } +}); + +eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) => { + try { + const {title, composer} = req.body || {}; + if (!title) { + res.status(400).send({status: 'BAD_REQUEST', message: 'title is required'}); + return; + } + const songId = await SongsAdminService.addSong(Number(req.params.eventId), title, composer || null); + res.status(201).send({songId}); + } catch (e: any) { + sendServerError(res, e); + } +}); + +/** + * @swagger + * /feedback/admin/events/{eventId}/songs/order: + * put: + * summary: Bulk reorder an event's setlist + * description: Rewrites song positions as a dense 0..n-1 sequence in one transaction. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [songIds] + * properties: + * songIds: + * type: array + * items: + * type: integer + * responses: + * 200: + * description: Reordered + * 401: + * description: Unauthorized + */ +eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => { + try { + const songIds: number[] = req.body?.songIds || []; + await EventsAdminService.reorderSongs(Number(req.params.eventId), songIds); + res.status(200).send({status: 'OK'}); + } catch (e: any) { + sendServerError(res, e); + } +}); + +/** + * @swagger + * /feedback/admin/events/{eventId}/questions: + * get: + * summary: Get an event's assigned questions + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Success + * 401: + * description: Unauthorized + * put: + * summary: Bulk-set an event's assigned questions + * description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [items] + * properties: + * items: + * type: array + * items: + * type: object + * properties: + * questionId: + * type: integer + * position: + * type: integer + * isActive: + * type: boolean + * responses: + * 200: + * description: Saved + * 401: + * description: Unauthorized + */ +eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => { + try { + const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId)); + if (!event) { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + res.status(200).send(event.questions); + } catch (e: any) { + sendServerError(res, e); + } +}); + +eventsAdminRouter.put('/:eventId/questions', async (req: Request, res: Response) => { + try { + const items = req.body?.items || []; + await EventsAdminService.setEventQuestions(Number(req.params.eventId), items); + res.status(200).send({status: 'OK'}); + } catch (e: any) { + sendServerError(res, e); + } +}); diff --git a/src/models/feedback/admin/events.admin.service.ts b/src/models/feedback/admin/events.admin.service.ts new file mode 100644 index 0000000..03a137d --- /dev/null +++ b/src/models/feedback/admin/events.admin.service.ts @@ -0,0 +1,288 @@ +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(); + } +}; diff --git a/src/models/feedback/admin/questions.admin.router.ts b/src/models/feedback/admin/questions.admin.router.ts new file mode 100644 index 0000000..bea0db8 --- /dev/null +++ b/src/models/feedback/admin/questions.admin.router.ts @@ -0,0 +1,186 @@ +/** + * Required External Modules and Interfaces + */ +import express, {Request, Response} from 'express'; +import {Guid} from 'guid-typescript'; +import logger from '../../../middleware/logger'; +import * as QuestionsAdminService from './questions.admin.service'; + +/** + * Router Definition + */ +export const questionsAdminRouter = express.Router(); + +const sendServerError = (res: Response, e: any) => { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); +}; + +/** + * @swagger + * /feedback/admin/questions: + * get: + * summary: List the question library + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: query + * name: includeArchived + * schema: + * type: boolean + * responses: + * 200: + * description: Success + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/AdminQuestion' + * 401: + * description: Unauthorized + * post: + * summary: Create a question + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [label, questionType] + * properties: + * label: + * type: string + * helpText: + * type: string + * questionType: + * $ref: '#/components/schemas/QuestionType' + * responses: + * 201: + * description: Created + * 400: + * description: Missing or invalid fields + * 401: + * description: Unauthorized + */ +questionsAdminRouter.get('/', async (req: Request, res: Response) => { + try { + const includeArchived = req.query.includeArchived === 'true'; + res.status(200).send(await QuestionsAdminService.listQuestions(includeArchived)); + } catch (e: any) { + sendServerError(res, e); + } +}); + +const VALID_TYPES = ['SONG_PICK', 'SONG_RATING', 'FREE_TEXT']; + +questionsAdminRouter.post('/', async (req: Request, res: Response) => { + try { + const {label, helpText, questionType} = req.body || {}; + if (!label || !VALID_TYPES.includes(questionType)) { + res.status(400).send({status: 'BAD_REQUEST', message: 'label and a valid questionType are required'}); + return; + } + const questionId = await QuestionsAdminService.createQuestion(label, helpText || null, questionType); + res.status(201).send({questionId}); + } catch (e: any) { + sendServerError(res, e); + } +}); + +/** + * @swagger + * /feedback/admin/questions/{questionId}: + * put: + * summary: Edit a question's label/help text + * description: question_type is immutable after creation - the admin UI offers "archive and create new" instead. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: questionId + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [label] + * properties: + * label: + * type: string + * helpText: + * type: string + * responses: + * 200: + * description: Updated + * 400: + * description: Missing label + * 404: + * description: Unknown question + * 401: + * description: Unauthorized + * delete: + * summary: Archive (or hard-delete) a question + * description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: questionId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Archived or deleted + * 404: + * description: Unknown question + * 401: + * description: Unauthorized + */ +questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => { + try { + const {label, helpText} = req.body || {}; + if (!label) { + res.status(400).send({status: 'BAD_REQUEST', message: 'label is required'}); + return; + } + const updated = await QuestionsAdminService.updateQuestion(Number(req.params.questionId), label, helpText || null); + if (!updated) { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + res.status(200).send({status: 'OK'}); + } catch (e: any) { + sendServerError(res, e); + } +}); + +questionsAdminRouter.delete('/:questionId', async (req: Request, res: Response) => { + try { + const result = await QuestionsAdminService.removeQuestion(Number(req.params.questionId)); + if (result === 'NOT_FOUND') { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + res.status(200).send({status: result}); + } catch (e: any) { + sendServerError(res, e); + } +}); diff --git a/src/models/feedback/admin/questions.admin.service.ts b/src/models/feedback/admin/questions.admin.service.ts new file mode 100644 index 0000000..e4fd385 --- /dev/null +++ b/src/models/feedback/admin/questions.admin.service.ts @@ -0,0 +1,98 @@ +import {NachklangFeedbackDB} from '../Feedback.db'; +import {QuestionType} from '../feedback.interface'; +import {AdminQuestion} from './admin.interface'; + +const mapRow = (row: any): AdminQuestion => ({ + questionId: row.question_id, + label: row.label, + helpText: row.help_text, + questionType: row.question_type, + isArchived: !!row.is_archived +}); + +export const listQuestions = async (includeArchived: boolean): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + const query = includeArchived + ? 'SELECT * FROM questions ORDER BY created_at DESC' + : 'SELECT * FROM questions WHERE is_archived = 0 ORDER BY created_at DESC'; + const rows = await conn.query(query); + return rows.map(mapRow); + } finally { + await conn.end(); + } +}; + +export const createQuestion = async (label: string, helpText: string | null, questionType: QuestionType): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + await conn.beginTransaction(); + const res = await conn.query( + 'INSERT INTO questions (label, help_text, question_type) VALUES (?,?,?) RETURNING question_id', + [label, helpText, questionType] + ); + await conn.commit(); + return res[0].question_id; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + await conn.end(); + } +}; + +/** + * Edits label/help text only. question_type is immutable after creation - + * changing it would invalidate existing answers' question_type_snapshot + * semantics. The admin UI offers "archive and create new" instead. + */ +export const updateQuestion = async (questionId: number, label: string, helpText: string | null): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + await conn.beginTransaction(); + const res = await conn.query('UPDATE questions SET label = ?, help_text = ? WHERE question_id = ?', [label, helpText, questionId]); + await conn.commit(); + return res.affectedRows > 0; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + await conn.end(); + } +}; + +export type RemoveQuestionResult = 'ARCHIVED' | 'DELETED' | 'NOT_FOUND'; + +/** + * Archives (soft delete) a question. Hard-deletes it instead if it has + * never been assigned to any event, so an admin's typo doesn't have to + * live forever in the library. + */ +export const removeQuestion = async (questionId: number): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + await conn.beginTransaction(); + + const existsRows = await conn.query('SELECT 1 FROM questions WHERE question_id = ?', [questionId]); + if (existsRows.length === 0) { + await conn.rollback(); + return 'NOT_FOUND'; + } + + const usageRows = await conn.query('SELECT 1 FROM event_questions WHERE question_id = ? LIMIT 1', [questionId]); + if (usageRows.length === 0) { + await conn.query('DELETE FROM questions WHERE question_id = ?', [questionId]); + await conn.commit(); + return 'DELETED'; + } + + await conn.query('UPDATE questions SET is_archived = 1 WHERE question_id = ?', [questionId]); + await conn.commit(); + return 'ARCHIVED'; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + await conn.end(); + } +}; diff --git a/src/models/feedback/admin/reports.admin.interface.ts b/src/models/feedback/admin/reports.admin.interface.ts new file mode 100644 index 0000000..91abdfb --- /dev/null +++ b/src/models/feedback/admin/reports.admin.interface.ts @@ -0,0 +1,75 @@ +import {QuestionType} from '../feedback.interface'; + +export interface SongPickResult { + songId: number; + title: string; + votes: number; +} + +export interface SongPickReport { + questionId: number | null; + label: string; + totalVotes: number; + results: SongPickResult[]; +} + +export interface SongRatingResult { + songId: number; + title: string; + average: number; + count: number; +} + +export interface SongRatingReport { + questionId: number | null; + label: string; + results: SongRatingResult[]; +} + +export interface FreeTextResponse { + submissionId: number; + submittedAt: string; + text: string; +} + +export interface FreeTextReport { + questionId: number | null; + label: string; + responses: FreeTextResponse[]; + hasMore: boolean; +} + +export interface EventReport { + event: { + eventId: number; + name: string; + eventDate: string; + feedbackDeadline: string; + }; + totalSubmissions: number; + firstSubmissionAt: string | null; + lastSubmissionAt: string | null; + songPicks: SongPickReport[]; + songRatings: SongRatingReport[]; + freeText: FreeTextReport[]; + guestBookCount: number; + newsletter: { + total: number; + sent: number; + pending: number; + failed: number; + }; +} + +/** Raw answer row as read from submission_answers, joined with submissions.submitted_at. */ +export interface AnswerRow { + submissionId: number; + submittedAt: string; + questionId: number | null; + questionLabel: string; + questionType: QuestionType; + songId: number | null; + songTitle: string | null; + rating: number | null; + textAnswer: string | null; +} diff --git a/src/models/feedback/admin/reports.admin.router.ts b/src/models/feedback/admin/reports.admin.router.ts new file mode 100644 index 0000000..2685935 --- /dev/null +++ b/src/models/feedback/admin/reports.admin.router.ts @@ -0,0 +1,210 @@ +/** + * Required External Modules and Interfaces + */ +import express, {Request, Response} from 'express'; +import {Guid} from 'guid-typescript'; +import logger from '../../../middleware/logger'; +import * as ReportsAdminService from './reports.admin.service'; +import * as CsvService from './csv.service'; +import * as EventsAdminService from './events.admin.service'; + +/** + * Router Definition + */ +export const reportsAdminRouter = express.Router(); + +const sendServerError = (res: Response, e: any) => { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); +}; + +/** + * @swagger + * /feedback/admin/events/{eventId}/report: + * get: + * summary: Aggregated feedback report for one event + * description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Success + * 404: + * description: Unknown event + * 401: + * description: Unauthorized + */ +reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => { + try { + const report = await ReportsAdminService.getReport(Number(req.params.eventId)); + if (!report) { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + res.status(200).send(report); + } catch (e: any) { + sendServerError(res, e); + } +}); + +/** + * @swagger + * /feedback/admin/events/{eventId}/guestbook: + * get: + * summary: Guest Book entries for one event + * description: Newest first, paginated. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: pageSize + * schema: + * type: integer + * responses: + * 200: + * description: Success + * 401: + * description: Unauthorized + */ +reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => { + try { + const page = Math.max(1, Number(req.query.page) || 1); + const pageSize = Math.min(200, Math.max(1, Number(req.query.pageSize) || 50)); + const result = await ReportsAdminService.getGuestBookEntries(Number(req.params.eventId), page, pageSize); + res.status(200).send(result); + } catch (e: any) { + sendServerError(res, e); + } +}); + +/** + * @swagger + * /feedback/admin/events/{eventId}/newsletter: + * get: + * summary: Newsletter signups for one event + * description: Includes sync_status, so failures can be handled manually. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Success + * 401: + * description: Unauthorized + */ +reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => { + try { + res.status(200).send(await ReportsAdminService.getNewsletterSignups(Number(req.params.eventId))); + } catch (e: any) { + sendServerError(res, e); + } +}); + +/** + * @swagger + * /feedback/admin/events/{eventId}/export/responses.csv: + * get: + * summary: CSV export of all answers for one event + * description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: CSV file + * content: + * text/csv: {} + * 401: + * description: Unauthorized + */ +reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => { + try { + const eventId = Number(req.params.eventId); + const event = await EventsAdminService.getEventAdmin(eventId); + if (!event) { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + const csv = await CsvService.buildResponsesCsv(eventId); + res.status(200) + .set('Content-Type', 'text/csv; charset=utf-8') + .set('Content-Disposition', `attachment; filename="nachklang-feedback-${event.slug}.csv"`) + .send(csv); + } catch (e: any) { + sendServerError(res, e); + } +}); + +/** + * @swagger + * /feedback/admin/events/{eventId}/export/guestbook.csv: + * get: + * summary: CSV export of Guest Book entries for one event + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: eventId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: CSV file + * content: + * text/csv: {} + * 401: + * description: Unauthorized + */ +reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => { + try { + const eventId = Number(req.params.eventId); + const event = await EventsAdminService.getEventAdmin(eventId); + if (!event) { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + const csv = await CsvService.buildGuestBookCsv(eventId); + res.status(200) + .set('Content-Type', 'text/csv; charset=utf-8') + .set('Content-Disposition', `attachment; filename="nachklang-feedback-guestbook-${event.slug}.csv"`) + .send(csv); + } catch (e: any) { + sendServerError(res, e); + } +}); diff --git a/src/models/feedback/admin/reports.admin.service.ts b/src/models/feedback/admin/reports.admin.service.ts new file mode 100644 index 0000000..34dec6c --- /dev/null +++ b/src/models/feedback/admin/reports.admin.service.ts @@ -0,0 +1,209 @@ +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} +): EventReport => { + const groupKey = (row: AnswerRow) => `${row.questionId ?? 'null'}::${row.questionLabel}`; + + const songPickGroups = new Map(); + const songRatingGroups = new Map(); + const freeTextGroups = new Map(); + + 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(); + 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(); + 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 => { + 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}; + 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; + } + + 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; + submittedAt: string; + displayName: string | null; + message: string | null; +} + +export const getGuestBookEntries = async (eventId: number, page: number, pageSize: number): Promise<{entries: GuestBookEntry[]; total: number}> => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + const totalRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]); + const rows = await conn.query( + 'SELECT entry_id, created_at, display_name, message FROM guest_book_entries WHERE event_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', + [eventId, pageSize, (page - 1) * pageSize] + ); + return { + total: Number(totalRows[0].cnt), + entries: rows.map((r: any) => ({entryId: r.entry_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; +} + +export const getNewsletterSignups = async (eventId: number): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + const rows = await conn.query( + 'SELECT signup_id, first_name, last_name, email, consent_at, sync_status, last_error FROM newsletter_signups WHERE event_id = ? ORDER BY consent_at DESC', + [eventId] + ); + return 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(); + } +}; diff --git a/src/models/feedback/admin/songs.admin.router.ts b/src/models/feedback/admin/songs.admin.router.ts new file mode 100644 index 0000000..ad0faaf --- /dev/null +++ b/src/models/feedback/admin/songs.admin.router.ts @@ -0,0 +1,110 @@ +/** + * Required External Modules and Interfaces + */ +import express, {Request, Response} from 'express'; +import {Guid} from 'guid-typescript'; +import logger from '../../../middleware/logger'; +import * as SongsAdminService from './songs.admin.service'; + +/** + * Router Definition + */ +export const songsAdminRouter = express.Router(); + +/** + * @swagger + * /feedback/admin/songs/{songId}: + * put: + * summary: Edit a song's title/composer + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: songId + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [title] + * properties: + * title: + * type: string + * composer: + * type: string + * responses: + * 200: + * description: Updated + * 400: + * description: Missing title + * 404: + * description: Unknown song + * 401: + * description: Unauthorized + * delete: + * summary: Remove a song + * description: Past answers keep their song_title_snapshot even after the song is removed. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: songId + * required: true + * schema: + * type: integer + * responses: + * 204: + * description: Removed + * 404: + * description: Unknown song + * 401: + * description: Unauthorized + */ +songsAdminRouter.put('/:songId', async (req: Request, res: Response) => { + try { + const {title, composer} = req.body || {}; + if (!title) { + res.status(400).send({status: 'BAD_REQUEST', message: 'title is required'}); + return; + } + const updated = await SongsAdminService.updateSong(Number(req.params.songId), title, composer || null); + if (!updated) { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + res.status(200).send({status: 'OK'}); + } catch (e: any) { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); + } +}); + +songsAdminRouter.delete('/:songId', async (req: Request, res: Response) => { + try { + const deleted = await SongsAdminService.deleteSong(Number(req.params.songId)); + if (!deleted) { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + res.status(204).send(); + } catch (e: any) { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); + } +}); diff --git a/src/models/feedback/admin/songs.admin.service.ts b/src/models/feedback/admin/songs.admin.service.ts new file mode 100644 index 0000000..12d075e --- /dev/null +++ b/src/models/feedback/admin/songs.admin.service.ts @@ -0,0 +1,56 @@ +import {NachklangFeedbackDB} from '../Feedback.db'; + +export const addSong = async (eventId: number, title: string, composer: string | null): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + await conn.beginTransaction(); + const posRows = await conn.query('SELECT COALESCE(MAX(position), -1) + 1 as next_position FROM songs WHERE event_id = ?', [eventId]); + const position = posRows[0].next_position; + const res = await conn.query( + 'INSERT INTO songs (event_id, title, composer, position) VALUES (?,?,?,?) RETURNING song_id', + [eventId, title, composer, position] + ); + await conn.commit(); + return res[0].song_id; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + await conn.end(); + } +}; + +export const updateSong = async (songId: number, title: string, composer: string | null): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + await conn.beginTransaction(); + const res = await conn.query('UPDATE songs SET title = ?, composer = ? WHERE song_id = ?', [title, composer, songId]); + await conn.commit(); + return res.affectedRows > 0; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + await conn.end(); + } +}; + +/** + * Removes a song. submission_answers rows referencing it keep their + * song_title_snapshot (song_id is set to NULL via ON DELETE SET NULL) - + * past answers still say what song was rated, even after the song is gone. + */ +export const deleteSong = async (songId: number): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + await conn.beginTransaction(); + const res = await conn.query('DELETE FROM songs WHERE song_id = ?', [songId]); + await conn.commit(); + return res.affectedRows > 0; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + await conn.end(); + } +}; diff --git a/src/models/feedback/feedback.auth.ts b/src/models/feedback/feedback.auth.ts new file mode 100644 index 0000000..d569ef7 --- /dev/null +++ b/src/models/feedback/feedback.auth.ts @@ -0,0 +1,85 @@ +import express from 'express'; +import {Guid} from 'guid-typescript'; +import logger from '../../middleware/logger'; +import * as UserService from '../calendar/users/users.service'; + +/** + * This file is the ONLY place in the feedback module that knows how admin + * authentication works today. No route handler and no service outside this + * file may import users.service, read session headers, or touch bcrypt. + * + * Today: reuses the existing Calendar users/sessions mechanism. Any + * activated @nachklang.art account may administer feedback — no roles. + * Migrating to Keycloak later means writing a keycloakJwtAuthenticator + * below and changing the one `activeAuthenticator` binding (plus the + * frontend's login route handler) — nothing else in the feedback module + * needs to change. + * + * Explicitly forbidden: accepting sessionId/sessionKey from query + * parameters, even "temporarily". That is the exact mistake documented in + * DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials + * end up in access logs, browser history, proxy logs, and Referer headers. + * Headers only. + */ + +// The only thing the rest of the feedback module knows about an admin. +export interface AdminIdentity { + id: string; + email: string; + displayName: string; +} + +// Pluggable strategy: extract + verify credentials from a request. +// Returns the identity, or null if unauthenticated. Throws only on +// infrastructure errors (e.g. the DB being unreachable). +export type AdminAuthenticator = (req: express.Request) => Promise; + +// Current implementation: reads X-Session-Id / X-Session-Key headers, +// delegates to the existing calendar UserService.checkSession(...). +export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => { + const sessionId = req.header('X-Session-Id'); + const sessionKey = req.header('X-Session-Key'); + if (!sessionId || !sessionKey) { + return null; + } + + const ip = req.ip || ''; + const user = await UserService.checkSession(sessionId, sessionKey, ip); + + // Mirrors the Calendar domain's own convention: a valid session on an + // inactive (not yet activated) account is not sufficient. + if (!user || !user.isActive) { + return null; + } + + return { + id: String(user.userId), + email: user.email, + displayName: user.fullName + }; +}; + +// Swap point: change this one binding to migrate to Keycloak. +export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator; + +// Express middleware used by every admin route. On success: +// res.locals.admin = AdminIdentity, calls next(). On failure: 401. +export const requireAdminAuth: express.RequestHandler = async (req, res, next) => { + try { + const identity = await activeAuthenticator(req); + if (!identity) { + res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'}); + return; + } + res.locals.admin = identity; + next(); + } catch (e: any) { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); + } +}; diff --git a/src/models/feedback/feedback.interface.ts b/src/models/feedback/feedback.interface.ts new file mode 100644 index 0000000..dbbdd5e --- /dev/null +++ b/src/models/feedback/feedback.interface.ts @@ -0,0 +1,125 @@ +/** + * @swagger + * components: + * schemas: + * QuestionType: + * type: string + * enum: [SONG_PICK, SONG_RATING, FREE_TEXT] + * Song: + * type: object + * required: [songId, title, position] + * properties: + * songId: + * type: integer + * example: 44 + * title: + * type: string + * example: "Abendlied" + * composer: + * type: string + * nullable: true + * example: "Josef Rheinberger" + * position: + * type: integer + * example: 0 + * Question: + * type: object + * required: [eventQuestionId, questionId, type, label, position] + * properties: + * eventQuestionId: + * type: integer + * example: 12 + * questionId: + * type: integer + * example: 5 + * type: + * $ref: '#/components/schemas/QuestionType' + * label: + * type: string + * example: "Welches Stück hat Sie am meisten berührt?" + * helpText: + * type: string + * nullable: true + * position: + * type: integer + * example: 0 + * EventSummary: + * type: object + * required: [slug, name, eventDate, feedbackDeadline] + * properties: + * slug: + * type: string + * example: "sommerkonzert-2026" + * name: + * type: string + * example: "Sommerkonzert 2026" + * subtitle: + * type: string + * nullable: true + * eventDate: + * type: string + * format: date + * feedbackDeadline: + * type: string + * format: date-time + * EventConfig: + * allOf: + * - $ref: '#/components/schemas/EventSummary' + * - type: object + * properties: + * introText: + * type: string + * nullable: true + * songs: + * type: array + * items: + * $ref: '#/components/schemas/Song' + * questions: + * type: array + * items: + * $ref: '#/components/schemas/Question' + * ProcessingError: + * type: object + * properties: + * status: + * type: string + * example: PROCESSING_ERROR + * message: + * type: string + * example: Internal Server Error. Try again later. + * reference: + * type: string + * example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3 + */ + +export type QuestionType = 'SONG_PICK' | 'SONG_RATING' | 'FREE_TEXT'; + +export interface Song { + songId: number; + title: string; + composer: string | null; + position: number; +} + +export interface Question { + eventQuestionId: number; + questionId: number; + type: QuestionType; + label: string; + helpText: string | null; + position: number; +} + +export interface EventSummary { + slug: string; + name: string; + subtitle: string | null; + eventDate: string; + feedbackDeadline: string; +} + +export interface EventConfig extends EventSummary { + introText: string | null; + songs: Song[]; + questions: Question[]; +} diff --git a/src/models/feedback/feedback.ratelimit.ts b/src/models/feedback/feedback.ratelimit.ts new file mode 100644 index 0000000..20d9475 --- /dev/null +++ b/src/models/feedback/feedback.ratelimit.ts @@ -0,0 +1,100 @@ +import * as crypto from 'crypto'; +import * as dotenv from 'dotenv'; +import {NachklangFeedbackDB} from './Feedback.db'; + +dotenv.config(); + +const RATE_LIMIT_MAX = parseInt(process.env.FEEDBACK_RATE_LIMIT_MAX || '5', 10); +const RATE_LIMIT_WINDOW_MIN = parseInt(process.env.FEEDBACK_RATE_LIMIT_WINDOW_MIN || '10', 10); +const RATE_LIMIT_WINDOW_MS = RATE_LIMIT_WINDOW_MIN * 60 * 1000; + +if (!process.env.FEEDBACK_IP_SALT) { + // A missing salt would silently degrade hashIp() to unsalted SHA-256, + // which is reversible for the whole IPv4 space in minutes - fail loudly + // instead of persisting deanonymizable data. + throw new Error('FEEDBACK_IP_SALT is required (see .env / CLAUDE.md environment block)'); +} +const IP_SALT = process.env.FEEDBACK_IP_SALT; + +/** + * Salted hash of the client IP. Never store or log the raw address. + */ +export const hashIp = (ip: string): string => { + return crypto.createHash('sha256').update(IP_SALT + ip).digest('hex'); +}; + +// In-memory sliding window, keyed by ip hash. Resets on process restart — +// acceptable, the DB backstop below covers that gap. +const recentSubmissions = new Map(); + +const pruneOld = (timestamps: number[], now: number): number[] => { + return timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS); +}; + +// Without this, isRateLimited() would store a Map entry for every distinct +// ip hash it has ever seen - including empty arrays for one-off visitors - +// and nothing would ever remove it, growing unbounded for the process +// lifetime. Sweep periodically so hashes that stop submitting eventually +// drop out even if isRateLimited() is never called for them again. +const sweepInterval = setInterval(() => { + const now = Date.now(); + for (const [ipHash, timestamps] of recentSubmissions) { + if (pruneOld(timestamps, now).length === 0) { + recentSubmissions.delete(ipHash); + } + } +}, RATE_LIMIT_WINDOW_MS); +sweepInterval.unref(); + +/** + * DB backstop for the case where the in-memory counter was reset by a + * process restart. Only queried when the in-memory counter is already + * near the limit, so the common path stays DB-free. + */ +const checkDbBackstop = async (ipHash: string): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + const query = 'SELECT COUNT(*) as cnt FROM submissions WHERE ip_hash = ? AND submitted_at > NOW() - INTERVAL ? MINUTE'; + const rows = await conn.query(query, [ipHash, RATE_LIMIT_WINDOW_MIN]); + return Number(rows[0].cnt); + } finally { + await conn.end(); + } +}; + +/** + * Returns true if the given ip hash is currently allowed to submit. + * Does not itself record the submission — call recordSubmission after a + * successful insert. + */ +export const isRateLimited = async (ipHash: string): Promise => { + const now = Date.now(); + const timestamps = pruneOld(recentSubmissions.get(ipHash) || [], now); + if (timestamps.length > 0) { + recentSubmissions.set(ipHash, timestamps); + } else { + recentSubmissions.delete(ipHash); + } + + if (timestamps.length >= RATE_LIMIT_MAX) { + return true; + } + + // Close to the limit in memory — fall back to the DB in case the + // process restarted and lost earlier counts. + if (timestamps.length >= RATE_LIMIT_MAX - 1) { + const dbCount = await checkDbBackstop(ipHash); + if (dbCount >= RATE_LIMIT_MAX) { + return true; + } + } + + return false; +}; + +export const recordSubmission = (ipHash: string): void => { + const now = Date.now(); + const timestamps = pruneOld(recentSubmissions.get(ipHash) || [], now); + timestamps.push(now); + recentSubmissions.set(ipHash, timestamps); +}; diff --git a/src/models/feedback/public/events.public.service.ts b/src/models/feedback/public/events.public.service.ts new file mode 100644 index 0000000..cbf6bad --- /dev/null +++ b/src/models/feedback/public/events.public.service.ts @@ -0,0 +1,102 @@ +import {NachklangFeedbackDB} from '../Feedback.db'; +import {EventConfig, EventSummary, Question, Song} from '../feedback.interface'; + +/** + * Returns all events currently eligible to receive feedback: + * published, on or after their concert day, and before the deadline. + */ +export const getEligibleEvents = async (): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + const query = ` + SELECT slug, name, subtitle, event_date, feedback_deadline + FROM events + WHERE is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW() + ORDER BY event_date DESC`; + const rows = await conn.query(query); + return rows.map((row: any) => ({ + slug: row.slug, + name: row.name, + subtitle: row.subtitle, + eventDate: row.event_date, + feedbackDeadline: row.feedback_deadline + })); + } finally { + await conn.end(); + } +}; + +export type EventLookupResult = + | { status: 'OK'; eventId: number; event: EventConfig } + | { status: 'NOT_FOUND' } + | { status: 'CLOSED' }; + +/** + * Resolves a slug to its full public config: meta, ordered setlist, ordered + * active questions. Distinguishes "unknown slug" from "known but outside + * its feedback window" so callers can respond 404 vs 410. Also used + * internally by the submission flow, which additionally needs `eventId`. + */ +export const getEventConfigBySlug = async (slug: string): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + const eventQuery = ` + SELECT event_id, slug, name, subtitle, event_date, feedback_deadline, intro_text, is_published + FROM events WHERE slug = ?`; + const eventRows = await conn.query(eventQuery, [slug]); + if (eventRows.length === 0) { + return {status: 'NOT_FOUND'}; + } + const eventRow = eventRows[0]; + + const eligibleQuery = ` + SELECT 1 FROM events + WHERE event_id = ? AND is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()`; + const eligibleRows = await conn.query(eligibleQuery, [eventRow.event_id]); + if (eligibleRows.length === 0) { + return {status: 'CLOSED'}; + } + + const songsQuery = 'SELECT song_id, title, composer, position FROM songs WHERE event_id = ? ORDER BY position ASC'; + const songRows = await conn.query(songsQuery, [eventRow.event_id]); + const songs: Song[] = songRows.map((row: any) => ({ + songId: row.song_id, + title: row.title, + composer: row.composer, + position: row.position + })); + + const questionsQuery = ` + SELECT eq.event_question_id, eq.position, q.question_id, q.question_type, q.label, q.help_text + FROM event_questions eq + INNER JOIN questions q ON q.question_id = eq.question_id + WHERE eq.event_id = ? AND eq.is_active = 1 + ORDER BY eq.position ASC`; + const questionRows = await conn.query(questionsQuery, [eventRow.event_id]); + const questions: Question[] = questionRows.map((row: any) => ({ + eventQuestionId: row.event_question_id, + questionId: row.question_id, + type: row.question_type, + label: row.label, + helpText: row.help_text, + position: row.position + })); + + return { + status: 'OK', + eventId: eventRow.event_id, + event: { + slug: eventRow.slug, + name: eventRow.name, + subtitle: eventRow.subtitle, + eventDate: eventRow.event_date, + feedbackDeadline: eventRow.feedback_deadline, + introText: eventRow.intro_text, + songs, + questions + } + }; + } finally { + await conn.end(); + } +}; diff --git a/src/models/feedback/public/public.router.ts b/src/models/feedback/public/public.router.ts new file mode 100644 index 0000000..2960392 --- /dev/null +++ b/src/models/feedback/public/public.router.ts @@ -0,0 +1,211 @@ +/** + * Required External Modules and Interfaces + */ +import express, {Request, Response} from 'express'; +import {Guid} from 'guid-typescript'; +import logger from '../../../middleware/logger'; +import {getEligibleEvents, getEventConfigBySlug} from './events.public.service'; +import {submitFeedback} from './submissions.service'; +import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit'; + +/** + * Router Definition + */ +export const publicRouter = express.Router(); + +/** + * True if the honeypot field was filled in — a real visitor never types + * into it, since it's hidden with CSS only. Pulled out as a pure function + * so the short-circuit behaviour is unit-testable without a live DB. + */ +export const isHoneypotTriggered = (body: any): boolean => { + return typeof body?.website === 'string' && body.website.trim().length > 0; +}; + +/** + * @swagger + * /feedback/events: + * get: + * summary: List currently eligible events + * description: Returns events that are published, on or after their concert day, and before their feedback deadline. An empty array is a valid, expected response. + * tags: + * - feedback + * responses: + * 200: + * description: Success + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/EventSummary' + * 500: + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProcessingError' + */ +publicRouter.get('/events', async (req: Request, res: Response) => { + try { + const events = await getEligibleEvents(); + res.status(200).send(events); + } catch (e: any) { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); + } +}); + +/** + * @swagger + * /feedback/events/{slug}: + * get: + * summary: Get the full public config for one event + * description: Returns event meta, ordered setlist, and ordered active questions. 404 if the slug is unknown, 410 if the event exists but is outside its feedback window. + * tags: + * - feedback + * parameters: + * - in: path + * name: slug + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Success + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/EventConfig' + * 404: + * description: Unknown slug + * 410: + * description: Event exists but feedback is closed + * 500: + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProcessingError' + */ +publicRouter.get('/events/:slug', async (req: Request, res: Response) => { + try { + const result = await getEventConfigBySlug(req.params.slug); + if (result.status === 'NOT_FOUND') { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + if (result.status === 'CLOSED') { + res.status(410).send({status: 'FEEDBACK_CLOSED'}); + return; + } + res.status(200).send(result.event); + } catch (e: any) { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); + } +}); + +/** + * @swagger + * /feedback/events/{slug}/submissions: + * post: + * summary: Submit feedback for an event + * description: Every field is optional; the only validation error the public form can produce is EMPTY_SUBMISSION (nothing was filled in). Rate-limited per IP hash and honeypot-checked. + * tags: + * - feedback + * parameters: + * - in: path + * name: slug + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SubmissionRequest' + * responses: + * 201: + * description: Submitted + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SubmissionResponse' + * 400: + * description: Nothing was filled in + * 404: + * description: Unknown slug + * 410: + * description: Event exists but feedback is closed + * 429: + * description: Rate limited + * 500: + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ProcessingError' + */ +publicRouter.post('/events/:slug/submissions', async (req: Request, res: Response) => { + try { + const body = req.body || {}; + + // Honeypot: a real visitor never fills this in. Fake success, persist + // nothing, stay silent about it having failed. + if (isHoneypotTriggered(body)) { + logger.info('Feedback honeypot triggered', {slug: req.params.slug}); + res.status(201).send({submissionId: -1}); + return; + } + + const ipHash = hashIp(req.ip || ''); + + if (await isRateLimited(ipHash)) { + res.status(429).send({status: 'RATE_LIMITED'}); + return; + } + + // Count every request that reaches this point against the limit, + // regardless of outcome - an attacker sending EMPTY/NOT_FOUND/CLOSED + // requests still costs DB round-trips per attempt and must not get an + // unlimited number of free ones. + recordSubmission(ipHash); + + const result = await submitFeedback(req.params.slug, body, ipHash); + + switch (result.status) { + case 'NOT_FOUND': + res.status(404).send({status: 'NOT_FOUND'}); + return; + case 'CLOSED': + res.status(410).send({status: 'FEEDBACK_CLOSED'}); + return; + case 'EMPTY': + res.status(400).send({status: 'EMPTY_SUBMISSION'}); + return; + case 'OK': + res.status(201).send({submissionId: result.submissionId}); + return; + } + } catch (e: any) { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + 'status': 'PROCESSING_ERROR', + 'message': 'Internal Server Error. Try again later.', + 'reference': errorGuid + }); + } +}); diff --git a/src/models/feedback/public/submission.interface.ts b/src/models/feedback/public/submission.interface.ts new file mode 100644 index 0000000..58b39a7 --- /dev/null +++ b/src/models/feedback/public/submission.interface.ts @@ -0,0 +1,95 @@ +/** + * @swagger + * components: + * schemas: + * SubmissionRequest: + * type: object + * properties: + * answers: + * type: array + * items: + * type: object + * properties: + * eventQuestionId: + * type: integer + * example: 12 + * songId: + * type: integer + * nullable: true + * description: SONG_PICK only + * ratings: + * type: array + * description: SONG_RATING only + * items: + * type: object + * properties: + * songId: + * type: integer + * rating: + * type: integer + * minimum: 1 + * maximum: 5 + * text: + * type: string + * nullable: true + * description: FREE_TEXT only + * guestBook: + * type: object + * nullable: true + * properties: + * displayName: + * type: string + * nullable: true + * message: + * type: string + * nullable: true + * newsletter: + * type: object + * nullable: true + * properties: + * firstName: + * type: string + * lastName: + * type: string + * email: + * type: string + * website: + * type: string + * description: Honeypot field. Must stay empty; a real visitor never fills it in. + * SubmissionResponse: + * type: object + * properties: + * submissionId: + * type: integer + * example: 91 + */ + +export interface RatingInput { + songId: number; + rating: number; +} + +export interface AnswerInput { + eventQuestionId: number; + songId?: number; + ratings?: RatingInput[]; + text?: string; +} + +export interface GuestBookInput { + displayName?: string; + message?: string; +} + +export interface NewsletterInput { + firstName: string; + lastName: string; + email: string; +} + +export interface SubmissionRequestBody { + answers?: AnswerInput[]; + guestBook?: GuestBookInput; + newsletter?: NewsletterInput; + website?: string; +} diff --git a/src/models/feedback/public/submissions.service.ts b/src/models/feedback/public/submissions.service.ts new file mode 100644 index 0000000..8e91145 --- /dev/null +++ b/src/models/feedback/public/submissions.service.ts @@ -0,0 +1,210 @@ +import {NachklangFeedbackDB} from '../Feedback.db'; +import {QuestionType} from '../feedback.interface'; +import {getEventConfigBySlug} from './events.public.service'; +import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface'; + +// Bump when the privacy/consent copy shown next to the newsletter opt-in +// changes; recorded per-signup so a past consent's exact wording is provable. +const CONSENT_TEXT_VERSION = '2026-08-02'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// A real setlist tops out around a few dozen songs and a handful of +// questions, so a legitimate submission never comes close to this. Caps +// total generated rows regardless of how large the client's answers/ratings +// arrays are, bounding the number of INSERTs one request can trigger. +export const MAX_ANSWER_ROWS = 200; + +export interface ValidatedAnswerRow { + eventQuestionId: number; + questionId: number; + label: string; + type: QuestionType; + position: number; + songId: number | null; + songTitle: string | null; + rating: number | null; + text: string | null; +} + +export interface ValidatedGuestBook { + displayName: string | null; + message: string | null; +} + +export interface ValidatedNewsletter { + firstName: string; + lastName: string; + email: string; +} + +/** + * Validates raw answers against the event's *actual* active questions and + * songs. Unknown eventQuestionId/songId are ignored rather than erroring — + * a stale tab must not lose someone's comment. Empty answers are dropped + * entirely; "no row" is the canonical representation of "skipped". + */ +export const validateAnswers = ( + answers: AnswerInput[], + questionsById: Map, + songTitleById: Map +): ValidatedAnswerRow[] => { + const rows: ValidatedAnswerRow[] = []; + const pushRow = (row: ValidatedAnswerRow): boolean => { + if (rows.length >= MAX_ANSWER_ROWS) return false; + rows.push(row); + return true; + }; + + outer: for (const answer of answers) { + const question = questionsById.get(answer.eventQuestionId); + if (!question) continue; + + if (question.type === 'SONG_PICK') { + if (answer.songId != null && songTitleById.has(answer.songId)) { + if (!pushRow({ + eventQuestionId: question.eventQuestionId, + questionId: question.questionId, + label: question.label, + type: 'SONG_PICK', + position: question.position, + songId: answer.songId, + songTitle: songTitleById.get(answer.songId)!, + rating: null, + text: null + })) break outer; + } + } else if (question.type === 'SONG_RATING') { + // De-duplicate by songId (last value wins) before generating rows, + // so a client can't force one row per repeated entry for the same + // song by simply repeating it in the ratings array. + const ratingBySong = new Map(); + for (const r of answer.ratings || []) { + if (!songTitleById.has(r.songId)) continue; + ratingBySong.set(r.songId, Math.min(5, Math.max(1, Math.round(r.rating)))); + } + for (const [songId, clamped] of ratingBySong) { + if (!pushRow({ + eventQuestionId: question.eventQuestionId, + questionId: question.questionId, + label: question.label, + type: 'SONG_RATING', + position: question.position, + songId, + songTitle: songTitleById.get(songId)!, + rating: clamped, + text: null + })) break outer; + } + } else if (question.type === 'FREE_TEXT') { + const trimmed = (answer.text || '').trim(); + if (trimmed.length > 0) { + if (!pushRow({ + eventQuestionId: question.eventQuestionId, + questionId: question.questionId, + label: question.label, + type: 'FREE_TEXT', + position: question.position, + songId: null, + songTitle: null, + rating: null, + text: trimmed.slice(0, 5000) + })) break outer; + } + } + } + + return rows; +}; + +export const validateGuestBook = (input?: GuestBookInput): ValidatedGuestBook | null => { + if (!input) return null; + const displayName = (input.displayName || '').trim().slice(0, 255) || null; + const message = (input.message || '').trim().slice(0, 2000) || null; + if (!displayName && !message) return null; + return {displayName, message}; +}; + +export const validateNewsletter = (input?: NewsletterInput): ValidatedNewsletter | null => { + if (!input) return null; + const firstName = (input.firstName || '').trim().slice(0, 120); + const lastName = (input.lastName || '').trim().slice(0, 120); + const email = (input.email || '').trim().slice(0, 255); + if (!firstName || !lastName || !EMAIL_RE.test(email)) return null; + return {firstName, lastName, email}; +}; + +export type SubmitResult = + | { status: 'OK'; submissionId: number } + | { status: 'NOT_FOUND' } + | { status: 'CLOSED' } + | { status: 'EMPTY' }; + +/** + * Validates and persists one feedback submission. Re-checks event + * eligibility (the window may have closed between page load and submit), + * validates every answer against the event's live questions/songs, then + * inserts everything in a single transaction. + */ +export const submitFeedback = async (slug: string, body: SubmissionRequestBody, ipHash: string | null): Promise => { + const lookup = await getEventConfigBySlug(slug); + if (lookup.status === 'NOT_FOUND') return {status: 'NOT_FOUND'}; + if (lookup.status === 'CLOSED') return {status: 'CLOSED'}; + const {eventId, event} = lookup; + + const questionsById = new Map(event.questions.map(q => [q.eventQuestionId, q])); + const songTitleById = new Map(event.songs.map(s => [s.songId, s.title])); + + const answerRows = validateAnswers(body.answers || [], questionsById, songTitleById); + const guestBook = validateGuestBook(body.guestBook); + const newsletter = validateNewsletter(body.newsletter); + + if (answerRows.length === 0 && !guestBook && !newsletter) { + return {status: 'EMPTY'}; + } + + let conn = await NachklangFeedbackDB.getConnection(); + try { + await conn.beginTransaction(); + + const subQuery = 'INSERT INTO submissions (event_id, ip_hash, has_guestbook, has_newsletter) VALUES (?,?,?,?) RETURNING submission_id'; + const subRes = await conn.query(subQuery, [eventId, ipHash, guestBook ? 1 : 0, newsletter ? 1 : 0]); + const submissionId = subRes[0].submission_id; + + for (const row of answerRows) { + const ansQuery = `INSERT INTO submission_answers + (submission_id, event_id, question_id, event_question_id, question_label_snapshot, question_type, position_snapshot, song_id, song_title_snapshot, rating, text_answer) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`; + await conn.query(ansQuery, [ + submissionId, eventId, row.questionId, row.eventQuestionId, row.label, row.type, row.position, + row.songId, row.songTitle, row.rating, row.text + ]); + } + + if (guestBook) { + const gbQuery = 'INSERT INTO guest_book_entries (submission_id, event_id, display_name, message) VALUES (?,?,?,?)'; + await conn.query(gbQuery, [submissionId, eventId, guestBook.displayName, guestBook.message]); + } + + if (newsletter) { + // SALESFORCE_ENABLED is false until Phase 4's contract is known; the + // signup is always persisted locally first regardless of sync outcome. + const salesforceEnabled = process.env.SALESFORCE_ENABLED === 'true'; + const nlQuery = `INSERT INTO newsletter_signups + (submission_id, event_id, first_name, last_name, email, consent_text_version, sync_status) + VALUES (?,?,?,?,?,?,?)`; + await conn.query(nlQuery, [ + submissionId, eventId, newsletter.firstName, newsletter.lastName, newsletter.email, + CONSENT_TEXT_VERSION, salesforceEnabled ? 'PENDING' : 'SKIPPED' + ]); + } + + await conn.commit(); + return {status: 'OK', submissionId}; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + await conn.end(); + } +}; diff --git a/test/feedback/csv.service.test.ts b/test/feedback/csv.service.test.ts new file mode 100644 index 0000000..950f9dc --- /dev/null +++ b/test/feedback/csv.service.test.ts @@ -0,0 +1,57 @@ +import {escapeCsvField, formatDatetime} from '../../src/models/feedback/admin/csv.service'; + +describe('escapeCsvField', () => { + it('passes plain text through unchanged', () => { + expect(escapeCsvField('Abendlied')).toBe('Abendlied'); + }); + + it('converts null/undefined to an empty string', () => { + expect(escapeCsvField(null)).toBe(''); + expect(escapeCsvField(undefined)).toBe(''); + }); + + it('converts numbers to strings', () => { + expect(escapeCsvField(5)).toBe('5'); + }); + + it('quotes and doubles internal quotes (RFC 4180)', () => { + expect(escapeCsvField('Sie sagte "Danke"')).toBe('"Sie sagte ""Danke"""'); + }); + + it('quotes a field containing the ; separator', () => { + expect(escapeCsvField('Rheinberger; Bach')).toBe('"Rheinberger; Bach"'); + }); + + it('strips embedded newlines instead of breaking the row', () => { + expect(escapeCsvField('Zeile 1\r\nZeile 2')).toBe('Zeile 1 Zeile 2'); + expect(escapeCsvField('Zeile 1\nZeile 2')).toBe('Zeile 1 Zeile 2'); + }); + + it('prefixes formula-injection characters with an apostrophe', () => { + expect(escapeCsvField('=1+1')).toBe("'=1+1"); + expect(escapeCsvField('+SUM(A1)')).toBe("'+SUM(A1)"); + expect(escapeCsvField('-2')).toBe("'-2"); + expect(escapeCsvField('@example')).toBe("'@example"); + }); + + it('does not treat a mid-string = as formula injection', () => { + expect(escapeCsvField('x = y')).toBe('x = y'); + }); +}); + +describe('formatDatetime', () => { + it('formats a Date as YYYY-MM-DD HH:mm:ss, not the verbose Date.toString()', () => { + const d = new Date(2026, 7, 2, 21, 59, 21); // month is 0-indexed: August + expect(formatDatetime(d)).toBe('2026-08-02 21:59:21'); + expect(formatDatetime(d)).not.toContain('GMT'); + }); + + it('pads single-digit components', () => { + const d = new Date(2026, 0, 5, 3, 4, 5); + expect(formatDatetime(d)).toBe('2026-01-05 03:04:05'); + }); + + it('returns an empty string for null', () => { + expect(formatDatetime(null)).toBe(''); + }); +}); diff --git a/test/feedback/events.admin.service.test.ts b/test/feedback/events.admin.service.test.ts new file mode 100644 index 0000000..dee0675 --- /dev/null +++ b/test/feedback/events.admin.service.test.ts @@ -0,0 +1,59 @@ +import {computeDefaultDeadline, slugBase, slugifyName} from '../../src/models/feedback/admin/events.admin.service'; + +describe('slugifyName', () => { + it('lowercases and hyphenates', () => { + expect(slugifyName('Sommerkonzert 2026')).toBe('sommerkonzert-2026'); + }); + + it('transliterates umlauts', () => { + expect(slugifyName('Frühlingskonzert')).toBe('fruehlingskonzert'); + expect(slugifyName('Weihnachtsgrüße')).toBe('weihnachtsgruesse'); + }); + + it('strips punctuation and collapses separators', () => { + expect(slugifyName('Konzert: "Klänge & Farben"!')).toBe('konzert-klaenge-farben'); + }); + + it('trims leading and trailing hyphens', () => { + expect(slugifyName(' -- Herbstkonzert -- ')).toBe('herbstkonzert'); + }); +}); + +describe('slugBase', () => { + it('appends the concert year when the name does not already carry it', () => { + expect(slugBase('Sommerkonzert', '2026-08-01')).toBe('sommerkonzert-2026'); + }); + + it('does not double up the year when the name already ends with it', () => { + expect(slugBase('Adventskonzert 2026', '2026-12-06')).toBe('adventskonzert-2026'); + }); + + it('still appends the year when the name contains a different year', () => { + expect(slugBase('Jubiläum 2020', '2026-08-01')).toBe('jubilaeum-2020-2026'); + }); +}); + +describe('computeDefaultDeadline', () => { + it('is 14 days after the event date, at 23:59:59', () => { + const deadline = computeDefaultDeadline('2026-08-01'); + expect(deadline.getFullYear()).toBe(2026); + expect(deadline.getMonth()).toBe(7); // August = index 7 + expect(deadline.getDate()).toBe(15); + expect(deadline.getHours()).toBe(23); + expect(deadline.getMinutes()).toBe(59); + expect(deadline.getSeconds()).toBe(59); + }); + + it('rolls over the month correctly', () => { + const deadline = computeDefaultDeadline('2026-08-25'); + expect(deadline.getMonth()).toBe(8); // September + expect(deadline.getDate()).toBe(8); + }); + + it('rolls over the year correctly', () => { + const deadline = computeDefaultDeadline('2026-12-25'); + expect(deadline.getFullYear()).toBe(2027); + expect(deadline.getMonth()).toBe(0); // January + expect(deadline.getDate()).toBe(8); + }); +}); diff --git a/test/feedback/feedback.auth.test.ts b/test/feedback/feedback.auth.test.ts new file mode 100644 index 0000000..75a9b50 --- /dev/null +++ b/test/feedback/feedback.auth.test.ts @@ -0,0 +1,87 @@ +import {Request, Response} from 'express'; + +jest.mock('../../src/models/calendar/users/users.service', () => ({ + checkSession: jest.fn() +})); + +import * as UserService from '../../src/models/calendar/users/users.service'; +import {requireAdminAuth, sessionHeaderAuthenticator} from '../../src/models/feedback/feedback.auth'; + +const mockCheckSession = UserService.checkSession as jest.Mock; + +const makeReq = (headers: Record): Request => { + return { + header: (name: string) => headers[name], + ip: '203.0.113.42' + } as unknown as Request; +}; + +const makeRes = (): Response => { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.send = jest.fn().mockReturnValue(res); + res.locals = {}; + return res as Response; +}; + +describe('sessionHeaderAuthenticator', () => { + beforeEach(() => mockCheckSession.mockReset()); + + it('returns null when headers are missing', async () => { + const identity = await sessionHeaderAuthenticator(makeReq({})); + expect(identity).toBeNull(); + expect(mockCheckSession).not.toHaveBeenCalled(); + }); + + it('returns null when checkSession finds no user', async () => { + mockCheckSession.mockResolvedValue(null); + const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'})); + expect(identity).toBeNull(); + }); + + it('returns null for a valid session on an inactive account', async () => { + mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: false}); + const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'})); + expect(identity).toBeNull(); + }); + + it('returns the identity for a valid session on an active account', async () => { + mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true}); + const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'})); + expect(identity).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'}); + }); + + it('passes the session id and key from headers through to checkSession, never from query params', async () => { + mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: true}); + await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '42', 'X-Session-Key': 'sekret'})); + expect(mockCheckSession).toHaveBeenCalledWith('42', 'sekret', '203.0.113.42'); + }); +}); + +describe('requireAdminAuth', () => { + beforeEach(() => mockCheckSession.mockReset()); + + it('responds 401 and does not call next() when unauthenticated', async () => { + mockCheckSession.mockResolvedValue(null); + const req = makeReq({}); + const res = makeRes(); + const next = jest.fn(); + + await requireAdminAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('sets res.locals.admin and calls next() when authenticated', async () => { + mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true}); + const req = makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}); + const res = makeRes(); + const next = jest.fn(); + + await requireAdminAuth(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.locals.admin).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'}); + }); +}); diff --git a/test/feedback/honeypot.test.ts b/test/feedback/honeypot.test.ts new file mode 100644 index 0000000..839dd68 --- /dev/null +++ b/test/feedback/honeypot.test.ts @@ -0,0 +1,16 @@ +import {isHoneypotTriggered} from '../../src/models/feedback/public/public.router'; + +describe('isHoneypotTriggered', () => { + it('is false when the field is absent', () => { + expect(isHoneypotTriggered({})).toBe(false); + }); + + it('is false when the field is empty', () => { + expect(isHoneypotTriggered({website: ''})).toBe(false); + expect(isHoneypotTriggered({website: ' '})).toBe(false); + }); + + it('is true when a bot filled it in', () => { + expect(isHoneypotTriggered({website: 'https://spam.example'})).toBe(true); + }); +}); diff --git a/test/feedback/ratelimit.salt-guard.test.ts b/test/feedback/ratelimit.salt-guard.test.ts new file mode 100644 index 0000000..4bbb76d --- /dev/null +++ b/test/feedback/ratelimit.salt-guard.test.ts @@ -0,0 +1,28 @@ +// Isolated from ratelimit.test.ts because it needs to control whether +// FEEDBACK_IP_SALT is present at module-load time, which a real dotenv.config() +// call would silently repopulate from the repo's .env file. +jest.mock('dotenv', () => ({config: jest.fn()})); +jest.mock('../../src/models/feedback/Feedback.db', () => ({ + NachklangFeedbackDB: {getConnection: jest.fn()} +})); + +describe('FEEDBACK_IP_SALT enforcement', () => { + const originalSalt = process.env.FEEDBACK_IP_SALT; + + afterEach(() => { + process.env.FEEDBACK_IP_SALT = originalSalt; + jest.resetModules(); + }); + + it('throws at import time when FEEDBACK_IP_SALT is unset, rather than silently hashing unsalted', () => { + jest.resetModules(); + delete process.env.FEEDBACK_IP_SALT; + expect(() => require('../../src/models/feedback/feedback.ratelimit')).toThrow(/FEEDBACK_IP_SALT/); + }); + + it('does not throw when FEEDBACK_IP_SALT is set', () => { + jest.resetModules(); + process.env.FEEDBACK_IP_SALT = 'a-real-salt'; + expect(() => require('../../src/models/feedback/feedback.ratelimit')).not.toThrow(); + }); +}); diff --git a/test/feedback/ratelimit.test.ts b/test/feedback/ratelimit.test.ts new file mode 100644 index 0000000..1411fc6 --- /dev/null +++ b/test/feedback/ratelimit.test.ts @@ -0,0 +1,20 @@ +import {hashIp} from '../../src/models/feedback/feedback.ratelimit'; + +describe('hashIp', () => { + it('never returns the raw IP', () => { + const hash = hashIp('203.0.113.42'); + expect(hash).not.toContain('203.0.113.42'); + }); + + it('is deterministic for the same input', () => { + expect(hashIp('203.0.113.42')).toBe(hashIp('203.0.113.42')); + }); + + it('differs for different inputs', () => { + expect(hashIp('203.0.113.42')).not.toBe(hashIp('203.0.113.43')); + }); + + it('is a 64-char hex SHA-256 digest', () => { + expect(hashIp('203.0.113.42')).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/test/feedback/reports.admin.service.test.ts b/test/feedback/reports.admin.service.test.ts new file mode 100644 index 0000000..4af9d7c --- /dev/null +++ b/test/feedback/reports.admin.service.test.ts @@ -0,0 +1,109 @@ +import {aggregateReport} from '../../src/models/feedback/admin/reports.admin.service'; +import {AnswerRow} from '../../src/models/feedback/admin/reports.admin.interface'; + +const eventMeta = {eventId: 1, name: 'Sommerkonzert', eventDate: '2026-08-01', feedbackDeadline: '2026-08-15T23:59:59'}; +const emptyNewsletter = {total: 0, sent: 0, pending: 0, failed: 0}; + +const row = (overrides: Partial): AnswerRow => ({ + submissionId: 1, + submittedAt: '2026-08-02T10:00:00.000Z', + questionId: 1, + questionLabel: 'Q', + questionType: 'FREE_TEXT', + songId: null, + songTitle: null, + rating: null, + textAnswer: null, + ...overrides +}); + +describe('aggregateReport - song picks', () => { + it('counts votes per song and sorts by votes descending', () => { + const answers: AnswerRow[] = [ + row({submissionId: 1, questionId: 5, questionLabel: 'Lieblingsstück?', questionType: 'SONG_PICK', songId: 10, songTitle: 'Abendlied'}), + row({submissionId: 2, questionId: 5, questionLabel: 'Lieblingsstück?', questionType: 'SONG_PICK', songId: 10, songTitle: 'Abendlied'}), + row({submissionId: 3, questionId: 5, questionLabel: 'Lieblingsstück?', questionType: 'SONG_PICK', songId: 11, songTitle: 'Morgenlied'}) + ]; + const report = aggregateReport(eventMeta, {totalSubmissions: 3, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter); + expect(report.songPicks).toHaveLength(1); + expect(report.songPicks[0].totalVotes).toBe(3); + expect(report.songPicks[0].results).toEqual([ + {songId: 10, title: 'Abendlied', votes: 2}, + {songId: 11, title: 'Morgenlied', votes: 1} + ]); + }); + + it('keeps separate SONG_PICK questions in separate groups', () => { + const answers: AnswerRow[] = [ + row({questionId: 5, questionLabel: 'Frage A', questionType: 'SONG_PICK', songId: 10, songTitle: 'Abendlied'}), + row({questionId: 6, questionLabel: 'Frage B', questionType: 'SONG_PICK', songId: 11, songTitle: 'Morgenlied'}) + ]; + const report = aggregateReport(eventMeta, {totalSubmissions: 2, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter); + expect(report.songPicks).toHaveLength(2); + }); +}); + +describe('aggregateReport - song ratings', () => { + it('averages ratings per song, rounded to one decimal, sorted descending', () => { + const answers: AnswerRow[] = [ + row({questionId: 7, questionLabel: 'Bewertung', questionType: 'SONG_RATING', songId: 10, songTitle: 'Abendlied', rating: 5}), + row({questionId: 7, questionLabel: 'Bewertung', questionType: 'SONG_RATING', songId: 10, songTitle: 'Abendlied', rating: 4}), + row({questionId: 7, questionLabel: 'Bewertung', questionType: 'SONG_RATING', songId: 11, songTitle: 'Morgenlied', rating: 3}) + ]; + const report = aggregateReport(eventMeta, {totalSubmissions: 2, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter); + expect(report.songRatings[0].results).toEqual([ + {songId: 10, title: 'Abendlied', average: 4.5, count: 2}, + {songId: 11, title: 'Morgenlied', average: 3, count: 1} + ]); + }); +}); + +describe('aggregateReport - free text', () => { + it('sorts newest first and caps at 500 with hasMore', () => { + const answers: AnswerRow[] = Array.from({length: 501}, (_, i) => + row({ + submissionId: i, + questionId: 9, + questionLabel: 'Sonstiges', + questionType: 'FREE_TEXT', + textAnswer: `Antwort ${i}`, + submittedAt: new Date(2026, 0, 1, 0, 0, i).toISOString() + }) + ); + const report = aggregateReport(eventMeta, {totalSubmissions: 501, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter); + expect(report.freeText[0].responses).toHaveLength(500); + expect(report.freeText[0].hasMore).toBe(true); + expect(report.freeText[0].responses[0].text).toBe('Antwort 500'); + }); + + it('does not set hasMore when at or under the cap', () => { + const answers: AnswerRow[] = [row({questionId: 9, questionLabel: 'Sonstiges', questionType: 'FREE_TEXT', textAnswer: 'Danke!'})]; + const report = aggregateReport(eventMeta, {totalSubmissions: 1, firstSubmissionAt: null, lastSubmissionAt: null}, answers, 0, emptyNewsletter); + expect(report.freeText[0].hasMore).toBe(false); + }); +}); + +describe('aggregateReport - top-level fields', () => { + it('passes through submission stats, guest book count, and newsletter counts unchanged', () => { + const report = aggregateReport( + eventMeta, + {totalSubmissions: 42, firstSubmissionAt: '2026-08-02T10:00:00.000Z', lastSubmissionAt: '2026-08-10T18:00:00.000Z'}, + [], + 7, + {total: 10, sent: 6, pending: 2, failed: 2} + ); + expect(report.totalSubmissions).toBe(42); + expect(report.firstSubmissionAt).toBe('2026-08-02T10:00:00.000Z'); + expect(report.lastSubmissionAt).toBe('2026-08-10T18:00:00.000Z'); + expect(report.guestBookCount).toBe(7); + expect(report.newsletter).toEqual({total: 10, sent: 6, pending: 2, failed: 2}); + expect(report.event).toEqual(eventMeta); + }); + + it('produces empty arrays for an event with no submissions', () => { + const report = aggregateReport(eventMeta, {totalSubmissions: 0, firstSubmissionAt: null, lastSubmissionAt: null}, [], 0, emptyNewsletter); + expect(report.songPicks).toEqual([]); + expect(report.songRatings).toEqual([]); + expect(report.freeText).toEqual([]); + }); +}); diff --git a/test/feedback/submissions.service.test.ts b/test/feedback/submissions.service.test.ts new file mode 100644 index 0000000..49e3f27 --- /dev/null +++ b/test/feedback/submissions.service.test.ts @@ -0,0 +1,151 @@ +import {MAX_ANSWER_ROWS, validateAnswers, validateGuestBook, validateNewsletter} from '../../src/models/feedback/public/submissions.service'; + +type QuestionLookup = Map; + +const songTitleById = new Map([ + [1, 'Abendlied'], + [2, 'Morgenlied'] +]); + +describe('validateAnswers', () => { + const questionsById: QuestionLookup = new Map([ + [10, {eventQuestionId: 10, questionId: 100, type: 'SONG_PICK', label: 'Lieblingsstück?', position: 0}], + [11, {eventQuestionId: 11, questionId: 101, type: 'SONG_RATING', label: 'Bewertung', position: 1}], + [12, {eventQuestionId: 12, questionId: 102, type: 'FREE_TEXT', label: 'Sonstiges', position: 2}] + ]); + + it('produces a row for a valid SONG_PICK answer', () => { + const rows = validateAnswers([{eventQuestionId: 10, songId: 1}], questionsById, songTitleById); + expect(rows).toEqual([ + {eventQuestionId: 10, questionId: 100, label: 'Lieblingsstück?', type: 'SONG_PICK', position: 0, songId: 1, songTitle: 'Abendlied', rating: null, text: null} + ]); + }); + + it('ignores a SONG_PICK answer with an unknown songId', () => { + const rows = validateAnswers([{eventQuestionId: 10, songId: 999}], questionsById, songTitleById); + expect(rows).toHaveLength(0); + }); + + it('ignores an answer for an unknown eventQuestionId', () => { + const rows = validateAnswers([{eventQuestionId: 999, songId: 1}], questionsById, songTitleById); + expect(rows).toHaveLength(0); + }); + + it('produces one row per rated song for SONG_RATING, ignoring unknown songs', () => { + const rows = validateAnswers([ + {eventQuestionId: 11, ratings: [{songId: 1, rating: 5}, {songId: 2, rating: 3}, {songId: 999, rating: 4}]} + ], questionsById, songTitleById); + expect(rows).toHaveLength(2); + expect(rows.map(r => r.songId)).toEqual([1, 2]); + }); + + it('clamps ratings to the 1..5 range', () => { + const rows = validateAnswers([ + {eventQuestionId: 11, ratings: [{songId: 1, rating: 9}, {songId: 2, rating: -3}]} + ], questionsById, songTitleById); + expect(rows.find(r => r.songId === 1)?.rating).toBe(5); + expect(rows.find(r => r.songId === 2)?.rating).toBe(1); + }); + + it('an unrated song in a SONG_RATING block produces no row', () => { + const rows = validateAnswers([{eventQuestionId: 11, ratings: []}], questionsById, songTitleById); + expect(rows).toHaveLength(0); + }); + + it('trims FREE_TEXT and drops it if empty after trimming', () => { + const withText = validateAnswers([{eventQuestionId: 12, text: ' Danke für den Abend! '}], questionsById, songTitleById); + expect(withText[0].text).toBe('Danke für den Abend!'); + + const blank = validateAnswers([{eventQuestionId: 12, text: ' '}], questionsById, songTitleById); + expect(blank).toHaveLength(0); + }); + + it('caps FREE_TEXT at 5000 characters', () => { + const long = 'a'.repeat(6000); + const rows = validateAnswers([{eventQuestionId: 12, text: long}], questionsById, songTitleById); + expect(rows[0].text).toHaveLength(5000); + }); + + it('a fully empty answer set produces no rows (skipped questions produce no rows)', () => { + const rows = validateAnswers([], questionsById, songTitleById); + expect(rows).toHaveLength(0); + }); + + it('de-duplicates repeated ratings for the same song, keeping the last value', () => { + const rows = validateAnswers([ + {eventQuestionId: 11, ratings: [{songId: 1, rating: 2}, {songId: 1, rating: 5}, {songId: 1, rating: 3}]} + ], questionsById, songTitleById); + expect(rows).toHaveLength(1); + expect(rows[0].rating).toBe(3); + }); + + it('caps total generated rows at MAX_ANSWER_ROWS regardless of how many ratings are submitted', () => { + const massRatings = Array.from({length: MAX_ANSWER_ROWS + 500}, (_, i) => ({ + songId: 1, + rating: (i % 5) + 1 + })); + // Force distinct songIds so de-duplication alone can't be the thing capping the count. + const distinctSongTitleById = new Map( + Array.from({length: MAX_ANSWER_ROWS + 500}, (_, i) => [i, `Song ${i}`]) + ); + const distinctRatings = massRatings.map((r, i) => ({songId: i, rating: r.rating})); + const rows = validateAnswers( + [{eventQuestionId: 11, ratings: distinctRatings}], + questionsById, + distinctSongTitleById + ); + expect(rows.length).toBe(MAX_ANSWER_ROWS); + }); + + it('stops adding rows across multiple answers once the cap is reached', () => { + const distinctSongTitleById = new Map( + Array.from({length: MAX_ANSWER_ROWS + 10}, (_, i) => [i, `Song ${i}`]) + ); + const answers = Array.from({length: MAX_ANSWER_ROWS + 10}, (_, i) => ({ + eventQuestionId: 10, + songId: i + })); + // SONG_PICK only ever produces 0 or 1 row per answer entry, so this + // exercises the cap across many separate answers, not one big array. + const rows = validateAnswers(answers, questionsById, distinctSongTitleById); + expect(rows.length).toBe(MAX_ANSWER_ROWS); + }); +}); + +describe('validateGuestBook', () => { + it('returns null when nothing was filled in', () => { + expect(validateGuestBook(undefined)).toBeNull(); + expect(validateGuestBook({displayName: ' ', message: ' '})).toBeNull(); + }); + + it('keeps a valid entry with only a display name', () => { + expect(validateGuestBook({displayName: 'Familie Müller'})).toEqual({displayName: 'Familie Müller', message: null}); + }); + + it('caps the message at 2000 characters', () => { + const long = 'x'.repeat(3000); + const result = validateGuestBook({message: long}); + expect(result?.message).toHaveLength(2000); + }); +}); + +describe('validateNewsletter', () => { + it('returns null when the object is missing', () => { + expect(validateNewsletter(undefined)).toBeNull(); + }); + + it('drops the signup silently when the email is invalid', () => { + expect(validateNewsletter({firstName: 'Anna', lastName: 'Beispiel', email: 'not-an-email'})).toBeNull(); + }); + + it('drops the signup when first or last name is missing', () => { + expect(validateNewsletter({firstName: '', lastName: 'Beispiel', email: 'a@b.de'})).toBeNull(); + expect(validateNewsletter({firstName: 'Anna', lastName: '', email: 'a@b.de'})).toBeNull(); + }); + + it('accepts a fully valid signup', () => { + expect(validateNewsletter({firstName: 'Anna', lastName: 'Beispiel', email: 'anna@beispiel.de'})).toEqual({ + firstName: 'Anna', lastName: 'Beispiel', email: 'anna@beispiel.de' + }); + }); +});