From 17ca6399e0c4899d8aa782b0a18168ef199b978b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20M=C3=BCller?= Date: Wed, 5 Aug 2026 23:32:22 +0200 Subject: [PATCH 1/9] Add Feedback domain module: public submission flow, admin CRUD, reporting New /feedback API domain backed by its own FEEDBACK_DB, mirroring the Calendar domain's router -> service -> DB pool layering: - Public endpoints (no auth): eligible-events listing, event config, submission with honeypot + rate limiting (in-memory + DB backstop). - Admin endpoints (session-header auth, reusing Calendar's users/sessions via a swappable feedback.auth.ts boundary): events/songs/questions CRUD, bulk reorder/assignment, aggregated reporting, CSV export. - Schema in sql/feedback/001_init.sql (8 tables), applied and verified against the real FEEDBACK_DB. - 64 Jest tests covering validation, auth, rate limiting, CSV escaping, and report aggregation (pure functions, no DB needed). Includes fixes from a security review: path traversal defense doesn't apply here (that's the frontend proxy, separate repo), but the rate-limiter cluster does - recordSubmission now counts every processed request (not just successful ones), the in-memory Map evicts empty entries instead of growing unbounded, FEEDBACK_IP_SALT is required at boot instead of silently degrading to unsalted hashing, and submission answer/rating arrays are capped and de-duplicated to bound insert amplification. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 9 +- app.ts | 19 +- sql/feedback/001_init.sql | 142 ++++++ src/models/feedback/Feedback.db.ts | 20 + src/models/feedback/Feedback.router.ts | 63 +++ src/models/feedback/admin/admin.interface.ts | 130 ++++++ src/models/feedback/admin/admin.router.ts | 52 +++ src/models/feedback/admin/csv.service.ts | 78 ++++ .../feedback/admin/events.admin.router.ts | 417 ++++++++++++++++++ .../feedback/admin/events.admin.service.ts | 288 ++++++++++++ .../feedback/admin/questions.admin.router.ts | 186 ++++++++ .../feedback/admin/questions.admin.service.ts | 98 ++++ .../feedback/admin/reports.admin.interface.ts | 75 ++++ .../feedback/admin/reports.admin.router.ts | 210 +++++++++ .../feedback/admin/reports.admin.service.ts | 209 +++++++++ .../feedback/admin/songs.admin.router.ts | 110 +++++ .../feedback/admin/songs.admin.service.ts | 56 +++ src/models/feedback/feedback.auth.ts | 85 ++++ src/models/feedback/feedback.interface.ts | 125 ++++++ src/models/feedback/feedback.ratelimit.ts | 100 +++++ .../feedback/public/events.public.service.ts | 102 +++++ src/models/feedback/public/public.router.ts | 211 +++++++++ .../feedback/public/submission.interface.ts | 95 ++++ .../feedback/public/submissions.service.ts | 210 +++++++++ test/feedback/csv.service.test.ts | 57 +++ test/feedback/events.admin.service.test.ts | 59 +++ test/feedback/feedback.auth.test.ts | 87 ++++ test/feedback/honeypot.test.ts | 16 + test/feedback/ratelimit.salt-guard.test.ts | 28 ++ test/feedback/ratelimit.test.ts | 20 + test/feedback/reports.admin.service.test.ts | 109 +++++ test/feedback/submissions.service.test.ts | 151 +++++++ 32 files changed, 3615 insertions(+), 2 deletions(-) create mode 100644 sql/feedback/001_init.sql create mode 100644 src/models/feedback/Feedback.db.ts create mode 100644 src/models/feedback/Feedback.router.ts create mode 100644 src/models/feedback/admin/admin.interface.ts create mode 100644 src/models/feedback/admin/admin.router.ts create mode 100644 src/models/feedback/admin/csv.service.ts create mode 100644 src/models/feedback/admin/events.admin.router.ts create mode 100644 src/models/feedback/admin/events.admin.service.ts create mode 100644 src/models/feedback/admin/questions.admin.router.ts create mode 100644 src/models/feedback/admin/questions.admin.service.ts create mode 100644 src/models/feedback/admin/reports.admin.interface.ts create mode 100644 src/models/feedback/admin/reports.admin.router.ts create mode 100644 src/models/feedback/admin/reports.admin.service.ts create mode 100644 src/models/feedback/admin/songs.admin.router.ts create mode 100644 src/models/feedback/admin/songs.admin.service.ts create mode 100644 src/models/feedback/feedback.auth.ts create mode 100644 src/models/feedback/feedback.interface.ts create mode 100644 src/models/feedback/feedback.ratelimit.ts create mode 100644 src/models/feedback/public/events.public.service.ts create mode 100644 src/models/feedback/public/public.router.ts create mode 100644 src/models/feedback/public/submission.interface.ts create mode 100644 src/models/feedback/public/submissions.service.ts create mode 100644 test/feedback/csv.service.test.ts create mode 100644 test/feedback/events.admin.service.test.ts create mode 100644 test/feedback/feedback.auth.test.ts create mode 100644 test/feedback/honeypot.test.ts create mode 100644 test/feedback/ratelimit.salt-guard.test.ts create mode 100644 test/feedback/ratelimit.test.ts create mode 100644 test/feedback/reports.admin.service.test.ts create mode 100644 test/feedback/submissions.service.test.ts 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' + }); + }); +}); -- 2.52.0 From 7488ac673f98497312014726fe7c75f3a768f494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20M=C3=BCller?= Date: Wed, 5 Aug 2026 23:37:17 +0200 Subject: [PATCH 2/9] Add example .env --- .env.example | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c208dac --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +PORT=3000 + +DB_HOST= +DB_USER= +DB_PASSWORD= + +EMAIL_HOST= +EMAIL_USERNAME= +EMAIL_PASSWORD= + +CALENDAR_DB= + +FEEDBACK_DB= +FEEDBACK_IP_SALT= +FEEDBACK_RATE_LIMIT_MAX=5 +FEEDBACK_RATE_LIMIT_WINDOW_MIN=10 +SALESFORCE_ENABLED=false +SALESFORCE_API_URL= +SALESFORCE_API_TOKEN= + +MEMBER_CREDENTIAL=123 +CHOIR_CREDENTIAL=123 +MANAGEMENT_CREDENTIAL=123 -- 2.52.0 From 56074d4441ad5b553953b14b8bc6a29e6784deb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20M=C3=BCller?= Date: Thu, 6 Aug 2026 22:55:32 +0200 Subject: [PATCH 3/9] Allow dev CORS from LAN IPs; add submission deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev-only CORS bypass in app.ts only ever matched http://localhost:, never the LAN IP a phone actually connects through over WiFi - so testing the feedback form from a real device against a local dev API had its submissions silently rejected by CORS. Extended the bypass to also allow private LAN ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x), dev-only as before. Also adds DELETE /feedback/admin/submissions/:submissionId (cascades to the submission's answers, guest book entry, and newsletter signup in explicit dependency order, single-path by submission_id) so an admin can remove an individual abusive/inappropriate entry - decided in IMPLEMENTATION_PLAN.md §7 item 8. getGuestBookEntries now also returns submissionId so the admin UI can target the delete call. Co-Authored-By: Claude Sonnet 5 --- app.ts | 11 +++-- src/models/feedback/admin/admin.router.ts | 45 +++++++++++++++++++ .../feedback/admin/reports.admin.service.ts | 44 +++++++++++++++++- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/app.ts b/app.ts index 0d70d99..4160e6a 100644 --- a/app.ts +++ b/app.ts @@ -40,15 +40,20 @@ let allowedHosts = [ ]; const isDev = process.env.NODE_ENV !== 'production'; const localhostRegex = /^http:\/\/localhost:\d+$/; +// Matches http://: - needed so the feedback form can +// be reached from a real phone over WiFi during dev (the phone's Origin is +// the dev machine's LAN IP, never "localhost"). Dev-only, same as above. +const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\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)) { + // Any localhost port, or a private-LAN IP, is fine outside production - + // dev servers pick whatever port is free (Next.js falls back from 3000 + // if it's taken), and real-device testing hits the dev machine by IP. + if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) { return callback(null, true); } diff --git a/src/models/feedback/admin/admin.router.ts b/src/models/feedback/admin/admin.router.ts index e0f7977..abf2a08 100644 --- a/src/models/feedback/admin/admin.router.ts +++ b/src/models/feedback/admin/admin.router.ts @@ -2,11 +2,14 @@ * Required External Modules and Interfaces */ import express, {Request, Response} from 'express'; +import {Guid} from 'guid-typescript'; +import logger from '../../../middleware/logger'; 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'; +import * as ReportsAdminService from './reports.admin.service'; /** * Router Definition @@ -46,6 +49,48 @@ adminRouter.get('/me', (req: Request, res: Response) => { res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName}); }); +/** + * @swagger + * /feedback/admin/submissions/{submissionId}: + * delete: + * summary: Delete a single submission + * description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: submissionId + * required: true + * schema: + * type: integer + * responses: + * 204: + * description: Deleted + * 404: + * description: Unknown submission + * 401: + * description: Unauthorized + */ +adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => { + try { + const deleted = await ReportsAdminService.deleteSubmission(Number(req.params.submissionId)); + 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 + }); + } +}); + adminRouter.use('/events', eventsAdminRouter); adminRouter.use('/events', reportsAdminRouter); adminRouter.use('/songs', songsAdminRouter); diff --git a/src/models/feedback/admin/reports.admin.service.ts b/src/models/feedback/admin/reports.admin.service.ts index 34dec6c..4042561 100644 --- a/src/models/feedback/admin/reports.admin.service.ts +++ b/src/models/feedback/admin/reports.admin.service.ts @@ -160,6 +160,7 @@ export const getReport = async (eventId: number): Promise => export interface GuestBookEntry { entryId: number; + submissionId: number; submittedAt: string; displayName: string | null; message: string | null; @@ -170,12 +171,18 @@ export const getGuestBookEntries = async (eventId: number, page: number, pageSiz 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 ?', + 'SELECT entry_id, submission_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})) + entries: rows.map((r: any) => ({ + entryId: r.entry_id, + submissionId: r.submission_id, + submittedAt: r.created_at, + displayName: r.display_name, + message: r.message + })) }; } finally { await conn.end(); @@ -192,6 +199,39 @@ export interface NewsletterSignupRow { lastError: string | null; } +/** + * Deletes one submission and everything under it (its answers, guest book + * entry, newsletter signup). Single-path deletes by submission_id - unlike + * deleteEvent's multi-path cascade issue, there's only one way to reach each + * child table here, so explicit ordering is for consistency with that + * function's style, not to work around an ambiguous-cascade error. + */ +export const deleteSubmission = async (submissionId: number): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + await conn.beginTransaction(); + + const rows = await conn.query('SELECT submission_id FROM submissions WHERE submission_id = ?', [submissionId]); + if (rows.length === 0) { + await conn.rollback(); + return false; + } + + await conn.query('DELETE FROM guest_book_entries WHERE submission_id = ?', [submissionId]); + await conn.query('DELETE FROM newsletter_signups WHERE submission_id = ?', [submissionId]); + await conn.query('DELETE FROM submission_answers WHERE submission_id = ?', [submissionId]); + await conn.query('DELETE FROM submissions WHERE submission_id = ?', [submissionId]); + + await conn.commit(); + return true; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + await conn.end(); + } +}; + export const getNewsletterSignups = async (eventId: number): Promise => { let conn = await NachklangFeedbackDB.getConnection(); try { -- 2.52.0 From 07d757e9afaf9c6f8fba9e53e4737ebf7988faa7 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Thu, 13 Aug 2026 21:02:47 +0200 Subject: [PATCH 4/9] Remove hardcoded USE statement from feedback schema migration 001_init.sql opened with USE `nachklang-feedback`, contradicting its own documented apply instructions (mysql ... < 001_init.sql, which already selects the database via the command line). Following the file's own usage note literally would fail unless a database happened to be named exactly nachklang-feedback rather than whatever FEEDBACK_DB is configured to in .env. Co-Authored-By: Claude Sonnet 5 --- sql/feedback/001_init.sql | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sql/feedback/001_init.sql b/sql/feedback/001_init.sql index 4fd61cc..ebd0c2d 100644 --- a/sql/feedback/001_init.sql +++ b/sql/feedback/001_init.sql @@ -6,8 +6,10 @@ -- -- Apply with e.g.: -- mysql -h -u -p < 001_init.sql - -USE `nachklang-feedback`; +-- +-- Deliberately no USE statement here: the target database is selected via +-- the mysql command line above (whatever FEEDBACK_DB is actually named in +-- .env), not hardcoded to a literal schema name. -- 1. events ------------------------------------------------------------- CREATE TABLE events ( -- 2.52.0 From 2988a70d8f724647eedb0fff47e6a5ee23d72a64 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Thu, 13 Aug 2026 21:32:32 +0200 Subject: [PATCH 5/9] Add search to the admin guest book endpoint At scale (many submissions after a concert), paging through the guest book 20 entries at a time with no way to find a specific person is impractical. Add an optional ?search= query param that filters entries whose name or message contains the term (case-insensitive), with LIKE wildcards escaped so a literal % or _ in a search term can't be misinterpreted as a pattern. Co-Authored-By: Claude Sonnet 5 --- .../feedback/admin/reports.admin.router.ts | 8 ++++++- .../feedback/admin/reports.admin.service.ts | 24 +++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/models/feedback/admin/reports.admin.router.ts b/src/models/feedback/admin/reports.admin.router.ts index 2685935..a048405 100644 --- a/src/models/feedback/admin/reports.admin.router.ts +++ b/src/models/feedback/admin/reports.admin.router.ts @@ -82,6 +82,11 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) = * name: pageSize * schema: * type: integer + * - in: query + * name: search + * description: Filters entries whose name or message contains this text (case-insensitive). + * schema: + * type: string * responses: * 200: * description: Success @@ -92,7 +97,8 @@ 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); + const search = typeof req.query.search === 'string' ? req.query.search : undefined; + const result = await ReportsAdminService.getGuestBookEntries(Number(req.params.eventId), page, pageSize, search); res.status(200).send(result); } 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 index 4042561..32f92da 100644 --- a/src/models/feedback/admin/reports.admin.service.ts +++ b/src/models/feedback/admin/reports.admin.service.ts @@ -166,13 +166,29 @@ export interface GuestBookEntry { message: string | null; } -export const getGuestBookEntries = async (eventId: number, page: number, pageSize: number): Promise<{entries: GuestBookEntry[]; total: number}> => { +// Escapes LIKE wildcards (% and _) so a search term is matched literally, +// not interpreted as a pattern - a search for "50%" must not match everything. +const escapeLikeTerm = (term: string) => term.replace(/[\\%_]/g, (c) => `\\${c}`); + +export const getGuestBookEntries = async ( + eventId: number, + page: number, + pageSize: number, + search?: string +): 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 trimmedSearch = search?.trim(); + const whereClause = trimmedSearch + ? 'WHERE event_id = ? AND (display_name LIKE ? ESCAPE \'\\\\\' OR message LIKE ? ESCAPE \'\\\\\')' + : 'WHERE event_id = ?'; + const likeParam = trimmedSearch ? `%${escapeLikeTerm(trimmedSearch)}%` : undefined; + const whereParams = trimmedSearch ? [eventId, likeParam, likeParam] : [eventId]; + + const totalRows = await conn.query(`SELECT COUNT(*) as cnt FROM guest_book_entries ${whereClause}`, whereParams); const rows = await conn.query( - 'SELECT entry_id, submission_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] + `SELECT entry_id, submission_id, created_at, display_name, message FROM guest_book_entries ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`, + [...whereParams, pageSize, (page - 1) * pageSize] ); return { total: Number(totalRows[0].cnt), -- 2.52.0 From 01a914f31e6853f470715eb18c68d2107c561b0f Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Thu, 13 Aug 2026 21:49:00 +0200 Subject: [PATCH 6/9] Paginate and add search to the newsletter signups endpoint Unlike guest book and free-text, newsletter signups had no LIMIT at all and the frontend rendered every row in one plain table. Newsletter opt-in is a single checkbox rather than typed text, so it's plausibly the largest per-event list - fix it the same way as guest book: paginated (page/pageSize, capped at 200/page) plus an optional ?search= over first name, last name, and email. Co-Authored-By: Claude Sonnet 5 --- .../feedback/admin/reports.admin.router.ts | 21 +++++++++++-- .../feedback/admin/reports.admin.service.ts | 30 ++++++++++++++----- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/models/feedback/admin/reports.admin.router.ts b/src/models/feedback/admin/reports.admin.router.ts index a048405..eed0244 100644 --- a/src/models/feedback/admin/reports.admin.router.ts +++ b/src/models/feedback/admin/reports.admin.router.ts @@ -110,7 +110,7 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response * /feedback/admin/events/{eventId}/newsletter: * get: * summary: Newsletter signups for one event - * description: Includes sync_status, so failures can be handled manually. + * description: Includes sync_status, so failures can be handled manually. Newest first, paginated. * tags: [feedback-admin] * parameters: * - $ref: '#/components/parameters/SessionIdHeader' @@ -120,6 +120,19 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response * required: true * schema: * type: integer + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: pageSize + * schema: + * type: integer + * - in: query + * name: search + * description: Filters entries whose first name, last name, or email contains this text (case-insensitive). + * schema: + * type: string * responses: * 200: * description: Success @@ -128,7 +141,11 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response */ reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => { try { - res.status(200).send(await ReportsAdminService.getNewsletterSignups(Number(req.params.eventId))); + const page = Math.max(1, Number(req.query.page) || 1); + const pageSize = Math.min(200, Math.max(1, Number(req.query.pageSize) || 50)); + const search = typeof req.query.search === 'string' ? req.query.search : undefined; + const result = await ReportsAdminService.getNewsletterSignups(Number(req.params.eventId), page, pageSize, search); + res.status(200).send(result); } 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 index 32f92da..5210e21 100644 --- a/src/models/feedback/admin/reports.admin.service.ts +++ b/src/models/feedback/admin/reports.admin.service.ts @@ -248,17 +248,33 @@ export const deleteSubmission = async (submissionId: number): Promise = } }; -export const getNewsletterSignups = async (eventId: number): Promise => { +export const getNewsletterSignups = async ( + eventId: number, + page: number, + pageSize: number, + search?: string +): Promise<{entries: NewsletterSignupRow[]; total: number}> => { let conn = await NachklangFeedbackDB.getConnection(); try { + const trimmedSearch = search?.trim(); + const whereClause = trimmedSearch + ? 'WHERE event_id = ? AND (first_name LIKE ? ESCAPE \'\\\\\' OR last_name LIKE ? ESCAPE \'\\\\\' OR email LIKE ? ESCAPE \'\\\\\')' + : 'WHERE event_id = ?'; + const likeParam = trimmedSearch ? `%${escapeLikeTerm(trimmedSearch)}%` : undefined; + const whereParams = trimmedSearch ? [eventId, likeParam, likeParam, likeParam] : [eventId]; + + const totalRows = await conn.query(`SELECT COUNT(*) as cnt FROM newsletter_signups ${whereClause}`, whereParams); 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] + `SELECT signup_id, first_name, last_name, email, consent_at, sync_status, last_error FROM newsletter_signups ${whereClause} ORDER BY consent_at DESC LIMIT ? OFFSET ?`, + [...whereParams, pageSize, (page - 1) * pageSize] ); - 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 - })); + return { + total: Number(totalRows[0].cnt), + entries: 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(); } -- 2.52.0 From 1a51b37097a69377e00d8a52ff4d5b085e78348c Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Fri, 14 Aug 2026 19:56:01 +0200 Subject: [PATCH 7/9] Wire up the Salesforce newsletter sync integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements integrations/salesforce.service.ts per the plan's §5.6 seam (syncNewsletterSignup(signupId)), against the real contract now that the Salesforce side exists (see the nk-salesforce repo's feature/newsletter-signup-integration branch): OAuth2 client-credentials auth, POST to /services/apexrest/newsletter/signup with {firstName, lastName, email, eventName}, response gives back which object (Lead or Person Account) and its id. Token is cached in memory with a conservative TTL and refreshed on a 401 rather than trusting expires_in, which Salesforce's client-credentials token response doesn't reliably return. submissions.service.ts now captures the newsletter_signups insert's id and fires syncNewsletterSignup after commit, fire-and-forget - the one piece that was previously entirely missing, so flipping SALESFORCE_ENABLED=true would have left every signup stuck at PENDING forever with nothing to process it (found during an earlier review pass). Replaced the placeholder SALESFORCE_API_TOKEN env var with SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET in .env.example and CLAUDE.md, matching the real auth mechanism instead of the static-token guess from before the contract was known. Also fixed CLAUDE.md's stale "still scaffolding-only" note about the Feedback domain. Not yet covered by tests - the Salesforce-side contract was validated end-to-end against a real sandbox, but this file has no unit tests yet. Co-Authored-By: Claude Sonnet 5 --- .env.example | 3 +- CLAUDE.md | 5 +- .../integrations/salesforce.service.ts | 145 ++++++++++++++++++ .../feedback/public/submissions.service.ts | 21 ++- 4 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 src/models/feedback/integrations/salesforce.service.ts diff --git a/.env.example b/.env.example index c208dac..77d02c6 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,8 @@ FEEDBACK_RATE_LIMIT_MAX=5 FEEDBACK_RATE_LIMIT_WINDOW_MIN=10 SALESFORCE_ENABLED=false SALESFORCE_API_URL= -SALESFORCE_API_TOKEN= +SALESFORCE_CLIENT_ID= +SALESFORCE_CLIENT_SECRET= MEMBER_CREDENTIAL=123 CHOIR_CREDENTIAL=123 diff --git a/CLAUDE.md b/CLAUDE.md index 2d5ba39..d201fb8 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. 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). +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/`: public submission flow, admin CRUD, reporting, and a Salesforce newsletter-sync integration). **Request path:** 1. `app.ts` mounts `Calendar.router.ts` at `/calendar` @@ -59,7 +59,8 @@ FEEDBACK_RATE_LIMIT_MAX= FEEDBACK_RATE_LIMIT_WINDOW_MIN= SALESFORCE_ENABLED= SALESFORCE_API_URL= -SALESFORCE_API_TOKEN= +SALESFORCE_CLIENT_ID= +SALESFORCE_CLIENT_SECRET= EMAIL_HOST= EMAIL_USERNAME= EMAIL_PASSWORD= diff --git a/src/models/feedback/integrations/salesforce.service.ts b/src/models/feedback/integrations/salesforce.service.ts new file mode 100644 index 0000000..5534fae --- /dev/null +++ b/src/models/feedback/integrations/salesforce.service.ts @@ -0,0 +1,145 @@ +import axios from 'axios'; +import {NachklangFeedbackDB} from '../Feedback.db'; +import logger from '../../../middleware/logger'; + +// Newsletter opt-ins sync to Salesforce, which already runs a full +// double-opt-in subscription flow (Person Account for existing constituents, +// Lead for everyone else - see the Salesforce repo's +// feature/newsletter-signup-integration branch for the full design notes). +// This is the one file that knows that contract exists; submissions.service.ts +// only ever calls syncNewsletterSignup(signupId) after its own transaction +// commits, fire-and-forget, so a Salesforce outage can never delay or fail a +// visitor's feedback submission. + +interface SalesforceSuccessResponse { + status: 'PENDING_CONFIRMATION' | 'ALREADY_SUBSCRIBED'; + salesforceObject: 'Lead' | 'Account'; + salesforceRecordId: string; + created: boolean; +} + +interface NewsletterSignupRow { + signup_id: number; + first_name: string; + last_name: string; + email: string; + event_name: string; +} + +// Salesforce's client-credentials token response does not reliably include +// expires_in, so the cache lifetime is a conservative guess rather than a +// value read from the response - a 401 on the next call just triggers a +// fresh fetch (see the retry-once logic in postSignup). +const TOKEN_CACHE_MS = 15 * 60 * 1000; +let cachedToken: {accessToken: string; fetchedAt: number} | null = null; + +const getAccessToken = async (forceRefresh: boolean): Promise => { + if (!forceRefresh && cachedToken && Date.now() - cachedToken.fetchedAt < TOKEN_CACHE_MS) { + return cachedToken.accessToken; + } + + const instanceUrl = process.env.SALESFORCE_API_URL; + const clientId = process.env.SALESFORCE_CLIENT_ID; + const clientSecret = process.env.SALESFORCE_CLIENT_SECRET; + if (!instanceUrl || !clientId || !clientSecret) { + throw new Error('SALESFORCE_ENABLED is true but SALESFORCE_API_URL/SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET are not fully configured.'); + } + + const res = await axios.post( + `${instanceUrl}/services/oauth2/token`, + new URLSearchParams({grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret}).toString(), + {headers: {'Content-Type': 'application/x-www-form-urlencoded'}, timeout: 10000} + ); + cachedToken = {accessToken: res.data.access_token, fetchedAt: Date.now()}; + return cachedToken.accessToken; +}; + +const postSignup = async (payload: {firstName: string; lastName: string; email: string; eventName: string}): Promise => { + const instanceUrl = process.env.SALESFORCE_API_URL; + const url = `${instanceUrl}/services/apexrest/newsletter/signup`; + + try { + const token = await getAccessToken(false); + const res = await axios.post(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000}); + return res.data; + } catch (err: any) { + // The cached token may have expired server-side even though our + // conservative local TTL hasn't - retry once with a forced refresh + // before treating this as a real failure. + if (err?.response?.status === 401) { + const token = await getAccessToken(true); + const res = await axios.post(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000}); + return res.data; + } + throw err; + } +}; + +const markSynced = async (signupId: number, externalId: string): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + await conn.query( + `UPDATE newsletter_signups SET sync_status = 'SENT', synced_at = NOW(), external_id = ?, sync_attempts = sync_attempts + 1, last_error = NULL WHERE signup_id = ?`, + [externalId, signupId] + ); + } finally { + await conn.end(); + } +}; + +const markFailed = async (signupId: number, errorMessage: string): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + await conn.query( + `UPDATE newsletter_signups SET sync_status = 'FAILED', last_error = ?, sync_attempts = sync_attempts + 1 WHERE signup_id = ?`, + [errorMessage.slice(0, 2000), signupId] + ); + } finally { + await conn.end(); + } +}; + +/** + * Reads one newsletter_signups row and syncs it to Salesforce. Always + * called after the owning submission's transaction has committed, never + * awaited by the request handler. When SALESFORCE_ENABLED is false, this + * only logs the payload it would have sent - the row's sync_status is + * already 'SKIPPED' from the insert in submissions.service.ts, so there's + * nothing to update. + */ +export const syncNewsletterSignup = async (signupId: number): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + let row: NewsletterSignupRow | undefined; + try { + const rows = await conn.query( + `SELECT ns.signup_id, ns.first_name, ns.last_name, ns.email, e.name AS event_name + FROM newsletter_signups ns JOIN events e ON e.event_id = ns.event_id + WHERE ns.signup_id = ?`, + [signupId] + ); + row = rows[0]; + } finally { + await conn.end(); + } + + if (!row) { + logger.error('syncNewsletterSignup: signup not found', {signupId}); + return; + } + + const payload = {firstName: row.first_name, lastName: row.last_name, email: row.email, eventName: row.event_name}; + + if (process.env.SALESFORCE_ENABLED !== 'true') { + logger.info('syncNewsletterSignup: SALESFORCE_ENABLED is false, would have sent', {signupId, payload}); + return; + } + + try { + const result = await postSignup(payload); + await markSynced(signupId, result.salesforceRecordId); + } catch (err: any) { + const message = err?.response?.data?.message || err?.message || 'Unknown error'; + logger.error('syncNewsletterSignup failed', {signupId, message}); + await markFailed(signupId, message); + } +}; diff --git a/src/models/feedback/public/submissions.service.ts b/src/models/feedback/public/submissions.service.ts index 8e91145..9d74c02 100644 --- a/src/models/feedback/public/submissions.service.ts +++ b/src/models/feedback/public/submissions.service.ts @@ -2,6 +2,8 @@ import {NachklangFeedbackDB} from '../Feedback.db'; import {QuestionType} from '../feedback.interface'; import {getEventConfigBySlug} from './events.public.service'; import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface'; +import {syncNewsletterSignup} from '../integrations/salesforce.service'; +import logger from '../../../middleware/logger'; // 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. @@ -186,20 +188,31 @@ export const submitFeedback = async (slug: string, body: SubmissionRequestBody, await conn.query(gbQuery, [submissionId, eventId, guestBook.displayName, guestBook.message]); } + let newsletterSignupId: number | null = null; if (newsletter) { - // SALESFORCE_ENABLED is false until Phase 4's contract is known; the - // signup is always persisted locally first regardless of sync outcome. + // The signup is always persisted locally first, regardless of sync + // outcome - syncNewsletterSignup (fired after commit, below) is what + // actually talks to Salesforce and moves PENDING to SENT/FAILED. 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, [ + VALUES (?,?,?,?,?,?,?) RETURNING signup_id`; + const nlRes = await conn.query(nlQuery, [ submissionId, eventId, newsletter.firstName, newsletter.lastName, newsletter.email, CONSENT_TEXT_VERSION, salesforceEnabled ? 'PENDING' : 'SKIPPED' ]); + newsletterSignupId = nlRes[0].signup_id; } await conn.commit(); + + if (newsletterSignupId !== null) { + const signupId = newsletterSignupId; + void syncNewsletterSignup(signupId).catch((err) => { + logger.error('syncNewsletterSignup threw outside its own error handling', {signupId, error: String(err)}); + }); + } + return {status: 'OK', submissionId}; } catch (err) { await conn.rollback(); -- 2.52.0 From 080b987914befc753e31d3bed471048848c4fc02 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Sat, 15 Aug 2026 17:45:06 +0200 Subject: [PATCH 8/9] Add unit tests for the Salesforce integration Covers integrations/salesforce.service.ts: disabled-mode logging-only path, signup-not-found, success (token fetch + POST + mark SENT), token reuse across calls, retry-once-on-401, failure marks FAILED with the error message, and a missing-client-credentials configuration error. 100% statement coverage on the file. Co-Authored-By: Claude Sonnet 5 --- test/feedback/salesforce.service.test.ts | 198 +++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 test/feedback/salesforce.service.test.ts diff --git a/test/feedback/salesforce.service.test.ts b/test/feedback/salesforce.service.test.ts new file mode 100644 index 0000000..a604ff9 --- /dev/null +++ b/test/feedback/salesforce.service.test.ts @@ -0,0 +1,198 @@ +// The module under test caches its OAuth token at module scope (see +// salesforce.service.ts's `cachedToken`), so every test resets the module +// registry for a clean cache. That also invalidates any jest.mock() factory +// instance captured before the reset, so every mocked dependency (axios, +// Feedback.db, the logger) is re-required fresh after each reset rather +// than referenced from a top-level import. + +jest.mock('axios'); +jest.mock('../../src/models/feedback/Feedback.db', () => ({ + NachklangFeedbackDB: {getConnection: jest.fn()} +})); +jest.mock('../../src/middleware/logger', () => ({ + __esModule: true, + default: {info: jest.fn(), error: jest.fn()} +})); + +const SIGNUP_ROW = { + signup_id: 7, + first_name: 'Erika', + last_name: 'Mustermann', + email: 'erika@example.com', + event_name: 'Sommerkonzert 2026' +}; + +const makeConn = (rows: any[]) => ({ + query: jest.fn().mockResolvedValue(rows), + end: jest.fn().mockResolvedValue(undefined) +}); + +// Re-requires every mocked dependency fresh (see the note above) and +// returns the live references plus the service under test. +const freshImports = () => { + const axios = require('axios'); + const {NachklangFeedbackDB} = require('../../src/models/feedback/Feedback.db'); + const logger = require('../../src/middleware/logger').default; + const {syncNewsletterSignup} = require('../../src/models/feedback/integrations/salesforce.service'); + return {axios, mockGetConnection: NachklangFeedbackDB.getConnection as jest.Mock, logger, syncNewsletterSignup}; +}; + +const ORIGINAL_ENV = {...process.env}; + +describe('syncNewsletterSignup - disabled mode', () => { + beforeEach(() => { + jest.resetModules(); + process.env = {...ORIGINAL_ENV, SALESFORCE_ENABLED: 'false'}; + }); + + it('logs the payload it would send and does not touch the network or write to the DB', async () => { + const {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports(); + const conn = makeConn([SIGNUP_ROW]); + mockGetConnection.mockResolvedValue(conn); + + await syncNewsletterSignup(7); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('would have sent'), + expect.objectContaining({ + signupId: 7, + payload: {firstName: 'Erika', lastName: 'Mustermann', email: 'erika@example.com', eventName: 'Sommerkonzert 2026'} + }) + ); + expect(axios.post).not.toHaveBeenCalled(); + // One read connection only - no UPDATE issued, since the row's + // sync_status is already 'SKIPPED' from the insert. + expect(mockGetConnection).toHaveBeenCalledTimes(1); + }); + + it('logs and returns without calling the network when the signup row does not exist', async () => { + const {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports(); + mockGetConnection.mockResolvedValue(makeConn([])); + + await syncNewsletterSignup(999); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('not found'), {signupId: 999}); + expect(axios.post).not.toHaveBeenCalled(); + }); +}); + +describe('syncNewsletterSignup - enabled mode', () => { + beforeEach(() => { + jest.resetModules(); + process.env = { + ...ORIGINAL_ENV, + SALESFORCE_ENABLED: 'true', + SALESFORCE_API_URL: 'https://example.my.salesforce.com', + SALESFORCE_CLIENT_ID: 'client-id', + SALESFORCE_CLIENT_SECRET: 'client-secret' + }; + }); + + it('fetches a token, posts the signup, and marks the row SENT with the returned record id', async () => { + const {axios, mockGetConnection, syncNewsletterSignup} = freshImports(); + const updateConn = makeConn([]); + mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn); + axios.post.mockImplementation((url: string) => { + if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}}); + if (url.endsWith('/services/apexrest/newsletter/signup')) { + return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q1234', created: true}}); + } + throw new Error(`unexpected url ${url}`); + }); + + await syncNewsletterSignup(7); + + expect(axios.post).toHaveBeenCalledWith( + 'https://example.my.salesforce.com/services/oauth2/token', + expect.any(String), + expect.objectContaining({headers: {'Content-Type': 'application/x-www-form-urlencoded'}}) + ); + expect(axios.post).toHaveBeenCalledWith( + 'https://example.my.salesforce.com/services/apexrest/newsletter/signup', + {firstName: 'Erika', lastName: 'Mustermann', email: 'erika@example.com', eventName: 'Sommerkonzert 2026'}, + expect.objectContaining({headers: {Authorization: 'Bearer tok-1'}}) + ); + expect(updateConn.query).toHaveBeenCalledWith(expect.stringContaining("sync_status = 'SENT'"), ['00Q1234', 7]); + }); + + it('reuses the cached token across two calls instead of fetching twice', async () => { + const {axios, mockGetConnection, syncNewsletterSignup} = freshImports(); + mockGetConnection + .mockResolvedValueOnce(makeConn([SIGNUP_ROW])) + .mockResolvedValueOnce(makeConn([])) + .mockResolvedValueOnce(makeConn([SIGNUP_ROW])) + .mockResolvedValueOnce(makeConn([])); + axios.post.mockImplementation((url: string) => { + if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}}); + return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q1234', created: true}}); + }); + + await syncNewsletterSignup(7); + await syncNewsletterSignup(7); + + const tokenCalls = axios.post.mock.calls.filter(([url]: [string]) => url.endsWith('/services/oauth2/token')); + expect(tokenCalls).toHaveLength(1); + }); + + it('retries once with a fresh token on a 401, then succeeds', async () => { + const {axios, mockGetConnection, syncNewsletterSignup} = freshImports(); + const updateConn = makeConn([]); + mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn); + + let tokenFetches = 0; + axios.post.mockImplementation((url: string) => { + if (url.endsWith('/services/oauth2/token')) { + tokenFetches += 1; + return Promise.resolve({data: {access_token: `tok-${tokenFetches}`}}); + } + if (url.endsWith('/services/apexrest/newsletter/signup')) { + if (tokenFetches === 1) { + const err: any = new Error('Unauthorized'); + err.response = {status: 401, data: {message: 'Session expired'}}; + return Promise.reject(err); + } + return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q9999', created: true}}); + } + throw new Error(`unexpected url ${url}`); + }); + + await syncNewsletterSignup(7); + + expect(tokenFetches).toBe(2); + expect(updateConn.query).toHaveBeenCalledWith(expect.stringContaining("sync_status = 'SENT'"), ['00Q9999', 7]); + }); + + it('marks the row FAILED with the error message on a non-401 error, without throwing', async () => { + const {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports(); + const updateConn = makeConn([]); + mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn); + axios.post.mockImplementation((url: string) => { + if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}}); + const err: any = new Error('Internal error'); + err.response = {status: 500, data: {message: 'The newsletter signup could not be processed.'}}; + return Promise.reject(err); + }); + + await expect(syncNewsletterSignup(7)).resolves.toBeUndefined(); + + expect(updateConn.query).toHaveBeenCalledWith( + expect.stringContaining("sync_status = 'FAILED'"), + ['The newsletter signup could not be processed.', 7] + ); + expect(logger.error).toHaveBeenCalledWith('syncNewsletterSignup failed', expect.objectContaining({signupId: 7})); + }); + + it('marks the row FAILED with a clear message when client credentials are not configured', async () => { + process.env.SALESFORCE_CLIENT_ID = ''; + const {mockGetConnection, syncNewsletterSignup} = freshImports(); + const updateConn = makeConn([]); + mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn); + + await syncNewsletterSignup(7); + + expect(updateConn.query).toHaveBeenCalledWith( + expect.stringContaining("sync_status = 'FAILED'"), + [expect.stringContaining('SALESFORCE_CLIENT_ID'), 7] + ); + }); +}); -- 2.52.0 From c2ddb11c4cee6cce7e19e1a442032616c2dba821 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Sat, 15 Aug 2026 17:45:37 +0200 Subject: [PATCH 9/9] Fix silent newsletter validation drops, surface skipped-sync visibility, consolidate duplicated helpers Three fixes from the earlier review, plus cleanup: - submissions.service.ts: a newsletter opt-in present but failing validation (e.g. malformed email) was silently dropped with no signal to the client - the rest of the submission saved, but the visitor had no way to know their newsletter signup didn't go through. Added newsletterDropped to the submit response so the frontend can tell them. - reports.admin.service.ts: the newsletter summary tracked total/sent/pending/failed but silently omitted SKIPPED (stub-mode) signups from any bucket - every current signup showed total>0 with every bucket reading 0, indistinguishable from "we don't know what happened". Added a skipped count. - Consolidated two things duplicated across the module: sendServerError (reimplemented ~11 times, three of those as identical local copies of the same function) into feedback.errors.ts, and formatDatetime/ toMysqlDatetime (the same local-time formatting logic under two names, in csv.service.ts and events.admin.service.ts respectively) into feedback.dates.ts. Co-Authored-By: Claude Sonnet 5 --- src/models/feedback/Feedback.router.ts | 11 ++----- src/models/feedback/admin/admin.router.ts | 11 ++----- src/models/feedback/admin/csv.service.ts | 10 +------ .../feedback/admin/events.admin.router.ts | 13 +------- .../feedback/admin/events.admin.service.ts | 8 ++--- .../feedback/admin/questions.admin.router.ts | 13 +------- .../feedback/admin/reports.admin.interface.ts | 1 + .../feedback/admin/reports.admin.router.ts | 13 +------- .../feedback/admin/reports.admin.service.ts | 5 ++-- .../feedback/admin/songs.admin.router.ts | 19 ++---------- src/models/feedback/feedback.auth.ts | 11 ++----- src/models/feedback/feedback.dates.ts | 13 ++++++++ src/models/feedback/feedback.errors.ts | 18 +++++++++++ src/models/feedback/public/public.router.ts | 30 ++++--------------- .../feedback/public/submission.interface.ts | 3 ++ .../feedback/public/submissions.service.ts | 10 +++++-- test/feedback/csv.service.test.ts | 3 +- test/feedback/reports.admin.service.test.ts | 6 ++-- 18 files changed, 72 insertions(+), 126 deletions(-) create mode 100644 src/models/feedback/feedback.dates.ts create mode 100644 src/models/feedback/feedback.errors.ts diff --git a/src/models/feedback/Feedback.router.ts b/src/models/feedback/Feedback.router.ts index 217cca4..655f31b 100644 --- a/src/models/feedback/Feedback.router.ts +++ b/src/models/feedback/Feedback.router.ts @@ -2,10 +2,9 @@ * 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'; +import {sendServerError} from './feedback.errors'; /** * Router Definition @@ -52,12 +51,6 @@ 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 - }); + sendServerError(res, e); } }); diff --git a/src/models/feedback/admin/admin.router.ts b/src/models/feedback/admin/admin.router.ts index abf2a08..e84b410 100644 --- a/src/models/feedback/admin/admin.router.ts +++ b/src/models/feedback/admin/admin.router.ts @@ -2,9 +2,8 @@ * Required External Modules and Interfaces */ import express, {Request, Response} from 'express'; -import {Guid} from 'guid-typescript'; -import logger from '../../../middleware/logger'; import {requireAdminAuth} from '../feedback.auth'; +import {sendServerError} from '../feedback.errors'; import {eventsAdminRouter} from './events.admin.router'; import {songsAdminRouter} from './songs.admin.router'; import {questionsAdminRouter} from './questions.admin.router'; @@ -81,13 +80,7 @@ adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Respo } 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 - }); + sendServerError(res, e); } }); diff --git a/src/models/feedback/admin/csv.service.ts b/src/models/feedback/admin/csv.service.ts index 0aa91f1..e7f899b 100644 --- a/src/models/feedback/admin/csv.service.ts +++ b/src/models/feedback/admin/csv.service.ts @@ -1,4 +1,5 @@ import {NachklangFeedbackDB} from '../Feedback.db'; +import {formatDatetime} from '../feedback.dates'; const CSV_SEPARATOR = ';'; const UTF8_BOM = ''; @@ -23,15 +24,6 @@ export const escapeCsvField = (value: string | number | null | undefined): strin 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) { diff --git a/src/models/feedback/admin/events.admin.router.ts b/src/models/feedback/admin/events.admin.router.ts index e6c075e..8bcaced 100644 --- a/src/models/feedback/admin/events.admin.router.ts +++ b/src/models/feedback/admin/events.admin.router.ts @@ -2,26 +2,15 @@ * 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'; +import {sendServerError} from '../feedback.errors'; /** * 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: diff --git a/src/models/feedback/admin/events.admin.service.ts b/src/models/feedback/admin/events.admin.service.ts index 03a137d..70075b2 100644 --- a/src/models/feedback/admin/events.admin.service.ts +++ b/src/models/feedback/admin/events.admin.service.ts @@ -1,6 +1,7 @@ import {NachklangFeedbackDB} from '../Feedback.db'; import {Song} from '../feedback.interface'; import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface'; +import {formatDatetime} from '../feedback.dates'; const UMLAUT_MAP: Record = { 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss', @@ -83,11 +84,6 @@ const generateUniqueSlug = async (conn: any, name: string, eventDate: string): P } }; -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 { @@ -101,7 +97,7 @@ export const createEvent = async (input: CreateEventInput, createdByEmail: strin 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), + slug, input.name, input.subtitle || null, input.eventDate, formatDatetime(deadline), input.introText || null, createdByEmail ]); await conn.commit(); diff --git a/src/models/feedback/admin/questions.admin.router.ts b/src/models/feedback/admin/questions.admin.router.ts index bea0db8..75fefb6 100644 --- a/src/models/feedback/admin/questions.admin.router.ts +++ b/src/models/feedback/admin/questions.admin.router.ts @@ -2,25 +2,14 @@ * 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'; +import {sendServerError} from '../feedback.errors'; /** * 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: diff --git a/src/models/feedback/admin/reports.admin.interface.ts b/src/models/feedback/admin/reports.admin.interface.ts index 91abdfb..a94e6ad 100644 --- a/src/models/feedback/admin/reports.admin.interface.ts +++ b/src/models/feedback/admin/reports.admin.interface.ts @@ -58,6 +58,7 @@ export interface EventReport { sent: number; pending: number; failed: number; + skipped: number; }; } diff --git a/src/models/feedback/admin/reports.admin.router.ts b/src/models/feedback/admin/reports.admin.router.ts index eed0244..6e82ad7 100644 --- a/src/models/feedback/admin/reports.admin.router.ts +++ b/src/models/feedback/admin/reports.admin.router.ts @@ -2,27 +2,16 @@ * 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'; +import {sendServerError} from '../feedback.errors'; /** * 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: diff --git a/src/models/feedback/admin/reports.admin.service.ts b/src/models/feedback/admin/reports.admin.service.ts index 5210e21..876fc9f 100644 --- a/src/models/feedback/admin/reports.admin.service.ts +++ b/src/models/feedback/admin/reports.admin.service.ts @@ -16,7 +16,7 @@ export const aggregateReport = ( submissionStats: {totalSubmissions: number; firstSubmissionAt: string | null; lastSubmissionAt: string | null}, answerRows: AnswerRow[], guestBookCount: number, - newsletterCounts: {total: number; sent: number; pending: number; failed: number} + newsletterCounts: {total: number; sent: number; pending: number; failed: number; skipped: number} ): EventReport => { const groupKey = (row: AnswerRow) => `${row.questionId ?? 'null'}::${row.questionLabel}`; @@ -137,13 +137,14 @@ export const getReport = async (eventId: number): Promise => `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}; + const newsletterCounts = {total: 0, sent: 0, pending: 0, failed: 0, skipped: 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; + else if (row.sync_status === 'SKIPPED') newsletterCounts.skipped = cnt; } return aggregateReport( diff --git a/src/models/feedback/admin/songs.admin.router.ts b/src/models/feedback/admin/songs.admin.router.ts index ad0faaf..52b9f71 100644 --- a/src/models/feedback/admin/songs.admin.router.ts +++ b/src/models/feedback/admin/songs.admin.router.ts @@ -2,9 +2,8 @@ * 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'; +import {sendServerError} from '../feedback.errors'; /** * Router Definition @@ -80,13 +79,7 @@ songsAdminRouter.put('/:songId', async (req: Request, res: Response) => { } 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 - }); + sendServerError(res, e); } }); @@ -99,12 +92,6 @@ songsAdminRouter.delete('/:songId', async (req: Request, res: Response) => { } 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 - }); + sendServerError(res, e); } }); diff --git a/src/models/feedback/feedback.auth.ts b/src/models/feedback/feedback.auth.ts index d569ef7..91cb292 100644 --- a/src/models/feedback/feedback.auth.ts +++ b/src/models/feedback/feedback.auth.ts @@ -1,7 +1,6 @@ import express from 'express'; -import {Guid} from 'guid-typescript'; -import logger from '../../middleware/logger'; import * as UserService from '../calendar/users/users.service'; +import {sendServerError} from './feedback.errors'; /** * This file is the ONLY place in the feedback module that knows how admin @@ -74,12 +73,6 @@ export const requireAdminAuth: express.RequestHandler = async (req, res, next) = 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 - }); + sendServerError(res, e); } }; diff --git a/src/models/feedback/feedback.dates.ts b/src/models/feedback/feedback.dates.ts new file mode 100644 index 0000000..d39cb65 --- /dev/null +++ b/src/models/feedback/feedback.dates.ts @@ -0,0 +1,13 @@ +/** + * Formats a Date using its local getters (not toISOString/UTC), so the + * wall-clock time the server is running in is what gets stored/displayed - + * never silently shifted by a UTC conversion. Used both for MySQL DATETIME + * literals (events.admin.service.ts) and CSV export (csv.service.ts): same + * requirement, same format, in either context. + */ +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())}`; +}; diff --git a/src/models/feedback/feedback.errors.ts b/src/models/feedback/feedback.errors.ts new file mode 100644 index 0000000..58b003d --- /dev/null +++ b/src/models/feedback/feedback.errors.ts @@ -0,0 +1,18 @@ +import {Response} from 'express'; +import {Guid} from 'guid-typescript'; +import logger from '../../middleware/logger'; + +/** + * The feedback module's standard catch-block response: log with a + * reference guid, never leak the real error message to the client. Every + * router in this module follows this exact convention (see CLAUDE.md). + */ +export const sendServerError = (res: Response, e: any): void => { + const 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/public.router.ts b/src/models/feedback/public/public.router.ts index 2960392..e5df0b3 100644 --- a/src/models/feedback/public/public.router.ts +++ b/src/models/feedback/public/public.router.ts @@ -2,11 +2,11 @@ * 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'; +import {sendServerError} from '../feedback.errors'; /** * Router Definition @@ -51,13 +51,7 @@ publicRouter.get('/events', async (req: Request, res: Response) => { 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 - }); + sendServerError(res, e); } }); @@ -106,13 +100,7 @@ publicRouter.get('/events/:slug', async (req: Request, res: Response) => { } 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 - }); + sendServerError(res, e); } }); @@ -166,7 +154,7 @@ publicRouter.post('/events/:slug/submissions', async (req: Request, res: Respons // 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}); + res.status(201).send({submissionId: -1, newsletterDropped: false}); return; } @@ -196,16 +184,10 @@ publicRouter.post('/events/:slug/submissions', async (req: Request, res: Respons res.status(400).send({status: 'EMPTY_SUBMISSION'}); return; case 'OK': - res.status(201).send({submissionId: result.submissionId}); + res.status(201).send({submissionId: result.submissionId, newsletterDropped: result.newsletterDropped}); 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 - }); + sendServerError(res, e); } }); diff --git a/src/models/feedback/public/submission.interface.ts b/src/models/feedback/public/submission.interface.ts index 58b39a7..933d098 100644 --- a/src/models/feedback/public/submission.interface.ts +++ b/src/models/feedback/public/submission.interface.ts @@ -62,6 +62,9 @@ * submissionId: * type: integer * example: 91 + * newsletterDropped: + * type: boolean + * description: True if the newsletter opt-in was present but failed validation (e.g. a malformed email) - the rest of the submission still saved. */ export interface RatingInput { diff --git a/src/models/feedback/public/submissions.service.ts b/src/models/feedback/public/submissions.service.ts index 9d74c02..8f13e4e 100644 --- a/src/models/feedback/public/submissions.service.ts +++ b/src/models/feedback/public/submissions.service.ts @@ -137,7 +137,7 @@ export const validateNewsletter = (input?: NewsletterInput): ValidatedNewsletter }; export type SubmitResult = - | { status: 'OK'; submissionId: number } + | { status: 'OK'; submissionId: number; newsletterDropped: boolean } | { status: 'NOT_FOUND' } | { status: 'CLOSED' } | { status: 'EMPTY' }; @@ -160,6 +160,12 @@ export const submitFeedback = async (slug: string, body: SubmissionRequestBody, const answerRows = validateAnswers(body.answers || [], questionsById, songTitleById); const guestBook = validateGuestBook(body.guestBook); const newsletter = validateNewsletter(body.newsletter); + // body.newsletter is only sent at all when the visitor had the opt-in + // checkbox on (see FeedbackForm.tsx), so a present-but-invalid block + // (e.g. a mistyped email) is distinguishable from "didn't opt in" - the + // rest of the submission still saves, but the client can tell the + // visitor their newsletter signup specifically didn't go through. + const newsletterDropped = !!body.newsletter && !newsletter; if (answerRows.length === 0 && !guestBook && !newsletter) { return {status: 'EMPTY'}; @@ -213,7 +219,7 @@ export const submitFeedback = async (slug: string, body: SubmissionRequestBody, }); } - return {status: 'OK', submissionId}; + return {status: 'OK', submissionId, newsletterDropped}; } catch (err) { await conn.rollback(); throw err; diff --git a/test/feedback/csv.service.test.ts b/test/feedback/csv.service.test.ts index 950f9dc..df00170 100644 --- a/test/feedback/csv.service.test.ts +++ b/test/feedback/csv.service.test.ts @@ -1,4 +1,5 @@ -import {escapeCsvField, formatDatetime} from '../../src/models/feedback/admin/csv.service'; +import {escapeCsvField} from '../../src/models/feedback/admin/csv.service'; +import {formatDatetime} from '../../src/models/feedback/feedback.dates'; describe('escapeCsvField', () => { it('passes plain text through unchanged', () => { diff --git a/test/feedback/reports.admin.service.test.ts b/test/feedback/reports.admin.service.test.ts index 4af9d7c..630960f 100644 --- a/test/feedback/reports.admin.service.test.ts +++ b/test/feedback/reports.admin.service.test.ts @@ -2,7 +2,7 @@ import {aggregateReport} from '../../src/models/feedback/admin/reports.admin.ser 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 emptyNewsletter = {total: 0, sent: 0, pending: 0, failed: 0, skipped: 0}; const row = (overrides: Partial): AnswerRow => ({ submissionId: 1, @@ -90,13 +90,13 @@ describe('aggregateReport - top-level fields', () => { {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} + {total: 10, sent: 6, pending: 2, failed: 1, skipped: 1} ); 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.newsletter).toEqual({total: 10, sent: 6, pending: 2, failed: 1, skipped: 1}); expect(report.event).toEqual(eventMeta); }); -- 2.52.0