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