Add Feedback domain module: public submission flow, admin CRUD, reporting (#7)
Jenkins Production Deployment

Co-authored-by: Patrick Müller <mail@pmueller.me>
Reviewed-on: #7
Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech>
Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-23 09:39:02 +00:00
committed by Patrick Müller
parent e7621b8290
commit b05f6b9da0
38 changed files with 4115 additions and 2 deletions
+20
View File
@@ -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();
};
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {publicRouter} from './public/public.router';
import {adminRouter} from './admin/admin.router';
import {sendServerError} from './feedback.errors';
/**
* 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) {
sendServerError(res, e);
}
});
@@ -0,0 +1,136 @@
/**
* @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
* posterImageUrl:
* type: string
* nullable: true
* 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;
posterImageUrl: string | null;
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;
posterImageUrl?: string;
}
export interface UpdateEventInput {
name?: string;
subtitle?: string;
eventDate?: string;
feedbackDeadline?: string;
isPublished?: boolean;
introText?: string;
posterImageUrl?: string;
}
export interface AdminQuestion {
questionId: number;
label: string;
helpText: string | null;
questionType: QuestionType;
isArchived: boolean;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../feedback.auth';
import {sendServerError} from '../feedback.errors';
import {eventsAdminRouter} from './events.admin.router';
import {songsAdminRouter} from './songs.admin.router';
import {questionsAdminRouter} from './questions.admin.router';
import {reportsAdminRouter} from './reports.admin.router';
import * as ReportsAdminService from './reports.admin.service';
/**
* 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});
});
/**
* @swagger
* /feedback/admin/submissions/{submissionId}:
* delete:
* summary: Delete a single submission
* description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool.
* tags: [feedback-admin]
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: submissionId
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Deleted
* 404:
* description: Unknown submission
* 401:
* description: Unauthorized
*/
adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => {
try {
const deleted = await ReportsAdminService.deleteSubmission(Number(req.params.submissionId));
if (!deleted) {
res.status(404).send({status: 'NOT_FOUND'});
return;
}
res.status(204).send();
} catch (e: any) {
sendServerError(res, e);
}
});
adminRouter.use('/events', eventsAdminRouter);
adminRouter.use('/events', reportsAdminRouter);
adminRouter.use('/songs', songsAdminRouter);
adminRouter.use('/questions', questionsAdminRouter);
+70
View File
@@ -0,0 +1,70 @@
import {NachklangFeedbackDB} from '../Feedback.db';
import {formatDatetime} from '../feedback.dates';
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;
};
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,408 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as EventsAdminService from './events.admin.service';
import * as SongsAdminService from './songs.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
*/
export const eventsAdminRouter = express.Router();
/**
* @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
* posterImageUrl:
* 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, posterImageUrl} = 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, posterImageUrl},
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,286 @@
import {NachklangFeedbackDB} from '../Feedback.db';
import {Song} from '../feedback.interface';
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface';
import {formatDatetime} from '../feedback.dates';
const UMLAUT_MAP: Record<string, string> = {
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss',
'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue'
};
/**
* Slug base from a name: lowercase, umlaut-transliterated, hyphenated.
* The caller appends the concert year and resolves collisions.
*/
export const slugifyName = (name: string): string => {
const transliterated = name.replace(/[äöüßÄÖÜ]/g, (ch) => UMLAUT_MAP[ch] || ch);
return transliterated
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
};
/**
* Default feedback deadline: event day + 14 days, end of day. Computed
* here (not by the DB) so the admin UI can pre-fill and override it.
*/
export const computeDefaultDeadline = (eventDateIso: string): Date => {
const [year, month, day] = eventDateIso.split('-').map(Number);
return new Date(year, month - 1, day + 14, 23, 59, 59);
};
const mapSummaryRow = (row: any): EventAdminSummary => ({
eventId: row.event_id,
slug: row.slug,
name: row.name,
subtitle: row.subtitle,
eventDate: row.event_date,
feedbackDeadline: row.feedback_deadline,
posterImageUrl: row.poster_image_url,
isPublished: !!row.is_published,
submissionCount: Number(row.submission_count)
});
export const listEventsAdmin = async (): Promise<EventAdminSummary[]> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = `
SELECT e.event_id, e.slug, e.name, e.subtitle, e.event_date, e.feedback_deadline, e.is_published,
COUNT(s.submission_id) as submission_count
FROM events e
LEFT JOIN submissions s ON s.event_id = e.event_id
GROUP BY e.event_id
ORDER BY e.event_date DESC`;
const rows = await conn.query(query);
return rows.map(mapSummaryRow);
} finally {
await conn.end();
}
};
/**
* Slug base with the concert year appended as a disambiguator - unless the
* name already ends with it (e.g. "Adventskonzert 2026"), which would
* otherwise double up as "adventskonzert-2026-2026".
*/
export const slugBase = (name: string, eventDateIso: string): string => {
const year = eventDateIso.split('-')[0];
const nameSlug = slugifyName(name);
return nameSlug.endsWith(`-${year}`) ? nameSlug : `${nameSlug}-${year}`;
};
const generateUniqueSlug = async (conn: any, name: string, eventDate: string): Promise<string> => {
const base = slugBase(name, eventDate);
let candidate = base;
let suffix = 2;
// Small table, small admin audience - a loop is simpler and safer than
// a clever single query, and collisions will be rare in practice.
while (true) {
const rows = await conn.query('SELECT 1 FROM events WHERE slug = ?', [candidate]);
if (rows.length === 0) return candidate;
candidate = `${base}-${suffix}`;
suffix++;
}
};
export const createEvent = async (input: CreateEventInput, createdByEmail: string): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const slug = await generateUniqueSlug(conn, input.name, input.eventDate);
const deadline = input.feedbackDeadline
? new Date(input.feedbackDeadline)
: computeDefaultDeadline(input.eventDate);
const query = `
INSERT INTO events (slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, created_by_email)
VALUES (?,?,?,?,?,?,?,?) RETURNING event_id`;
const res = await conn.query(query, [
slug, input.name, input.subtitle || null, input.eventDate, formatDatetime(deadline),
input.introText || null, input.posterImageUrl || null, createdByEmail
]);
await conn.commit();
return res[0].event_id;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const getEventAdmin = async (eventId: number): Promise<EventAdminDetail | null> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const eventRows = await conn.query(`
SELECT e.*, COUNT(s.submission_id) as submission_count
FROM events e
LEFT JOIN submissions s ON s.event_id = e.event_id
WHERE e.event_id = ?
GROUP BY e.event_id`, [eventId]);
if (eventRows.length === 0) return null;
const row = eventRows[0];
const songRows = await conn.query('SELECT song_id, title, composer, position FROM songs WHERE event_id = ? ORDER BY position ASC', [eventId]);
const songs: Song[] = songRows.map((r: any) => ({songId: r.song_id, title: r.title, composer: r.composer, position: r.position}));
const questionRows = await conn.query(
'SELECT event_question_id, question_id, position, is_active FROM event_questions WHERE event_id = ? ORDER BY position ASC',
[eventId]
);
const questions: EventAdminQuestionAssignment[] = questionRows.map((r: any) => ({
eventQuestionId: r.event_question_id, questionId: r.question_id, position: r.position, isActive: !!r.is_active
}));
return {
...mapSummaryRow(row),
introText: row.intro_text,
songs,
questions
};
} finally {
await conn.end();
}
};
export const updateEvent = async (eventId: number, input: UpdateEventInput): Promise<boolean> => {
const fields: string[] = [];
const values: any[] = [];
if (input.name !== undefined) { fields.push('name = ?'); values.push(input.name); }
if (input.subtitle !== undefined) { fields.push('subtitle = ?'); values.push(input.subtitle); }
if (input.eventDate !== undefined) { fields.push('event_date = ?'); values.push(input.eventDate); }
if (input.feedbackDeadline !== undefined) { fields.push('feedback_deadline = ?'); values.push(input.feedbackDeadline); }
if (input.isPublished !== undefined) { fields.push('is_published = ?'); values.push(input.isPublished ? 1 : 0); }
if (input.introText !== undefined) { fields.push('intro_text = ?'); values.push(input.introText); }
if (input.posterImageUrl !== undefined) { fields.push('poster_image_url = ?'); values.push(input.posterImageUrl || null); }
if (fields.length === 0) return true;
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
values.push(eventId);
const res = await conn.query(`UPDATE events SET ${fields.join(', ')} WHERE event_id = ?`, values);
await conn.commit();
return res.affectedRows > 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export type DeleteEventResult = 'DELETED' | 'NOT_FOUND' | 'HAS_SUBMISSIONS';
/**
* Deletes an event and everything under it. Children are deleted in
* explicit dependency order rather than left to the DB's ON DELETE CASCADE
* chain: submission_answers and guest_book_entries are reachable from
* `events` via two different cascade paths (direct event_id FK, and via
* `submissions`/`songs`), and MariaDB can reject that as an ambiguous
* multi-path cascade. See IMPLEMENTATION_PLAN.md Phase 1 notes.
*/
export const deleteEvent = async (eventId: number, force: boolean): Promise<DeleteEventResult> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const eventRows = await conn.query('SELECT event_id FROM events WHERE event_id = ?', [eventId]);
if (eventRows.length === 0) {
await conn.rollback();
return 'NOT_FOUND';
}
const countRows = await conn.query('SELECT COUNT(*) as cnt FROM submissions WHERE event_id = ?', [eventId]);
const submissionCount = Number(countRows[0].cnt);
if (submissionCount > 0 && !force) {
await conn.rollback();
return 'HAS_SUBMISSIONS';
}
await conn.query('DELETE FROM guest_book_entries WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM newsletter_signups WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM submission_answers WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM submissions WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM event_questions WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM songs WHERE event_id = ?', [eventId]);
await conn.query('DELETE FROM events WHERE event_id = ?', [eventId]);
await conn.commit();
return 'DELETED';
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const reorderSongs = async (eventId: number, songIds: number[]): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
for (let i = 0; i < songIds.length; i++) {
await conn.query('UPDATE songs SET position = ? WHERE song_id = ? AND event_id = ?', [i, songIds[i], eventId]);
}
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export interface QuestionAssignmentItem {
questionId: number;
position: number;
isActive: boolean;
}
/**
* Bulk-sets an event's assigned questions in one transaction: inserts new
* assignments, updates existing ones' position/active state, and removes
* ones no longer present in `items`.
*/
export const setEventQuestions = async (eventId: number, items: QuestionAssignmentItem[]): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const existingRows = await conn.query('SELECT question_id FROM event_questions WHERE event_id = ?', [eventId]);
const existingIds = new Set<number>(existingRows.map((r: any) => r.question_id));
const nextIds = new Set<number>(items.map((i) => i.questionId));
for (const existingId of existingIds) {
if (!nextIds.has(existingId)) {
await conn.query('DELETE FROM event_questions WHERE event_id = ? AND question_id = ?', [eventId, existingId]);
}
}
for (const item of items) {
if (existingIds.has(item.questionId)) {
await conn.query(
'UPDATE event_questions SET position = ?, is_active = ? WHERE event_id = ? AND question_id = ?',
[item.position, item.isActive ? 1 : 0, eventId, item.questionId]
);
} else {
await conn.query(
'INSERT INTO event_questions (event_id, question_id, position, is_active) VALUES (?,?,?,?)',
[eventId, item.questionId, item.position, item.isActive ? 1 : 0]
);
}
}
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
@@ -0,0 +1,175 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as QuestionsAdminService from './questions.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
*/
export const questionsAdminRouter = express.Router();
/**
* @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,76 @@
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;
skipped: 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,222 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as ReportsAdminService from './reports.admin.service';
import * as CsvService from './csv.service';
import * as EventsAdminService from './events.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
*/
export const reportsAdminRouter = express.Router();
/**
* @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
* - in: query
* name: search
* description: Filters entries whose name or message contains this text (case-insensitive).
* schema:
* type: string
* 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 search = typeof req.query.search === 'string' ? req.query.search : undefined;
const result = await ReportsAdminService.getGuestBookEntries(Number(req.params.eventId), page, pageSize, search);
res.status(200).send(result);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /feedback/admin/events/{eventId}/newsletter:
* get:
* summary: Newsletter signups for one event
* description: Includes sync_status, so failures can be handled manually. 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
* - in: query
* name: search
* description: Filters entries whose first name, last name, or email contains this text (case-insensitive).
* schema:
* type: string
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
*/
reportsAdminRouter.get('/:eventId/newsletter', 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 search = typeof req.query.search === 'string' ? req.query.search : undefined;
const result = await ReportsAdminService.getNewsletterSignups(Number(req.params.eventId), page, pageSize, search);
res.status(200).send(result);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @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,282 @@
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; skipped: 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, skipped: 0};
for (const row of newsletterRows) {
const cnt = Number(row.cnt);
newsletterCounts.total += cnt;
if (row.sync_status === 'SENT') newsletterCounts.sent = cnt;
else if (row.sync_status === 'PENDING') newsletterCounts.pending = cnt;
else if (row.sync_status === 'FAILED') newsletterCounts.failed = cnt;
else if (row.sync_status === 'SKIPPED') newsletterCounts.skipped = cnt;
}
return aggregateReport(
{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;
submissionId: number;
submittedAt: string;
displayName: string | null;
message: string | null;
}
// Escapes LIKE wildcards (% and _) so a search term is matched literally,
// not interpreted as a pattern - a search for "50%" must not match everything.
const escapeLikeTerm = (term: string) => term.replace(/[\\%_]/g, (c) => `\\${c}`);
export const getGuestBookEntries = async (
eventId: number,
page: number,
pageSize: number,
search?: string
): Promise<{entries: GuestBookEntry[]; total: number}> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const trimmedSearch = search?.trim();
const whereClause = trimmedSearch
? 'WHERE event_id = ? AND (display_name LIKE ? ESCAPE \'\\\\\' OR message LIKE ? ESCAPE \'\\\\\')'
: 'WHERE event_id = ?';
const likeParam = trimmedSearch ? `%${escapeLikeTerm(trimmedSearch)}%` : undefined;
const whereParams = trimmedSearch ? [eventId, likeParam, likeParam] : [eventId];
const totalRows = await conn.query(`SELECT COUNT(*) as cnt FROM guest_book_entries ${whereClause}`, whereParams);
const rows = await conn.query(
`SELECT entry_id, submission_id, created_at, display_name, message FROM guest_book_entries ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...whereParams, pageSize, (page - 1) * pageSize]
);
return {
total: Number(totalRows[0].cnt),
entries: rows.map((r: any) => ({
entryId: r.entry_id,
submissionId: r.submission_id,
submittedAt: r.created_at,
displayName: r.display_name,
message: r.message
}))
};
} finally {
await conn.end();
}
};
export interface NewsletterSignupRow {
signupId: number;
firstName: string;
lastName: string;
email: string;
consentAt: string;
syncStatus: string;
lastError: string | null;
}
/**
* Deletes one submission and everything under it (its answers, guest book
* entry, newsletter signup). Single-path deletes by submission_id - unlike
* deleteEvent's multi-path cascade issue, there's only one way to reach each
* child table here, so explicit ordering is for consistency with that
* function's style, not to work around an ambiguous-cascade error.
*/
export const deleteSubmission = async (submissionId: number): Promise<boolean> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.beginTransaction();
const rows = await conn.query('SELECT submission_id FROM submissions WHERE submission_id = ?', [submissionId]);
if (rows.length === 0) {
await conn.rollback();
return false;
}
await conn.query('DELETE FROM guest_book_entries WHERE submission_id = ?', [submissionId]);
await conn.query('DELETE FROM newsletter_signups WHERE submission_id = ?', [submissionId]);
await conn.query('DELETE FROM submission_answers WHERE submission_id = ?', [submissionId]);
await conn.query('DELETE FROM submissions WHERE submission_id = ?', [submissionId]);
await conn.commit();
return true;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const getNewsletterSignups = async (
eventId: number,
page: number,
pageSize: number,
search?: string
): Promise<{entries: NewsletterSignupRow[]; total: number}> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const trimmedSearch = search?.trim();
const whereClause = trimmedSearch
? 'WHERE event_id = ? AND (first_name LIKE ? ESCAPE \'\\\\\' OR last_name LIKE ? ESCAPE \'\\\\\' OR email LIKE ? ESCAPE \'\\\\\')'
: 'WHERE event_id = ?';
const likeParam = trimmedSearch ? `%${escapeLikeTerm(trimmedSearch)}%` : undefined;
const whereParams = trimmedSearch ? [eventId, likeParam, likeParam, likeParam] : [eventId];
const totalRows = await conn.query(`SELECT COUNT(*) as cnt FROM newsletter_signups ${whereClause}`, whereParams);
const rows = await conn.query(
`SELECT signup_id, first_name, last_name, email, consent_at, sync_status, last_error FROM newsletter_signups ${whereClause} ORDER BY consent_at DESC LIMIT ? OFFSET ?`,
[...whereParams, pageSize, (page - 1) * pageSize]
);
return {
total: Number(totalRows[0].cnt),
entries: rows.map((r: any) => ({
signupId: r.signup_id, firstName: r.first_name, lastName: r.last_name, email: r.email,
consentAt: r.consent_at, syncStatus: r.sync_status, lastError: r.last_error
}))
};
} finally {
await conn.end();
}
};
@@ -0,0 +1,97 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as SongsAdminService from './songs.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* 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) {
sendServerError(res, e);
}
});
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) {
sendServerError(res, e);
}
});
@@ -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();
}
};
+78
View File
@@ -0,0 +1,78 @@
import express from 'express';
import * as UserService from '../calendar/users/users.service';
import {sendServerError} from './feedback.errors';
/**
* This file is the ONLY place in the feedback module that knows how admin
* 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) {
sendServerError(res, e);
}
};
+13
View File
@@ -0,0 +1,13 @@
/**
* Formats a Date using its local getters (not toISOString/UTC), so the
* wall-clock time the server is running in is what gets stored/displayed -
* never silently shifted by a UTC conversion. Used both for MySQL DATETIME
* literals (events.admin.service.ts) and CSV export (csv.service.ts): same
* requirement, same format, in either context.
*/
export const formatDatetime = (value: Date | string | null): string => {
if (!value) return '';
const d = value instanceof Date ? value : new Date(value);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
+18
View File
@@ -0,0 +1,18 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger';
/**
* The feedback module's standard catch-block response: log with a
* reference guid, never leak the real error message to the client. Every
* router in this module follows this exact convention (see CLAUDE.md).
*/
export const sendServerError = (res: Response, e: any): void => {
const errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
status: 'PROCESSING_ERROR',
message: 'Internal Server Error. Try again later.',
reference: errorGuid
});
};
+130
View File
@@ -0,0 +1,130 @@
/**
* @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
* posterImageUrl:
* type: string
* nullable: true
* example: "https://www.nachklang.art/img/nk/image-20260727-214855-851.jpeg"
* 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;
posterImageUrl: string | null;
}
export interface EventConfig extends EventSummary {
introText: string | null;
songs: Song[];
questions: Question[];
}
+100
View File
@@ -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,145 @@
import axios from 'axios';
import {NachklangFeedbackDB} from '../Feedback.db';
import logger from '../../../middleware/logger';
// Newsletter opt-ins sync to Salesforce, which already runs a full
// double-opt-in subscription flow (Person Account for existing constituents,
// Lead for everyone else - see the Salesforce repo's
// feature/newsletter-signup-integration branch for the full design notes).
// This is the one file that knows that contract exists; submissions.service.ts
// only ever calls syncNewsletterSignup(signupId) after its own transaction
// commits, fire-and-forget, so a Salesforce outage can never delay or fail a
// visitor's feedback submission.
interface SalesforceSuccessResponse {
status: 'PENDING_CONFIRMATION' | 'ALREADY_SUBSCRIBED';
salesforceObject: 'Lead' | 'Account';
salesforceRecordId: string;
created: boolean;
}
interface NewsletterSignupRow {
signup_id: number;
first_name: string;
last_name: string;
email: string;
event_name: string;
}
// Salesforce's client-credentials token response does not reliably include
// expires_in, so the cache lifetime is a conservative guess rather than a
// value read from the response - a 401 on the next call just triggers a
// fresh fetch (see the retry-once logic in postSignup).
const TOKEN_CACHE_MS = 15 * 60 * 1000;
let cachedToken: {accessToken: string; fetchedAt: number} | null = null;
const getAccessToken = async (forceRefresh: boolean): Promise<string> => {
if (!forceRefresh && cachedToken && Date.now() - cachedToken.fetchedAt < TOKEN_CACHE_MS) {
return cachedToken.accessToken;
}
const instanceUrl = process.env.SALESFORCE_API_URL;
const clientId = process.env.SALESFORCE_CLIENT_ID;
const clientSecret = process.env.SALESFORCE_CLIENT_SECRET;
if (!instanceUrl || !clientId || !clientSecret) {
throw new Error('SALESFORCE_ENABLED is true but SALESFORCE_API_URL/SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET are not fully configured.');
}
const res = await axios.post(
`${instanceUrl}/services/oauth2/token`,
new URLSearchParams({grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret}).toString(),
{headers: {'Content-Type': 'application/x-www-form-urlencoded'}, timeout: 10000}
);
cachedToken = {accessToken: res.data.access_token, fetchedAt: Date.now()};
return cachedToken.accessToken;
};
const postSignup = async (payload: {firstName: string; lastName: string; email: string; eventName: string}): Promise<SalesforceSuccessResponse> => {
const instanceUrl = process.env.SALESFORCE_API_URL;
const url = `${instanceUrl}/services/apexrest/newsletter/signup`;
try {
const token = await getAccessToken(false);
const res = await axios.post<SalesforceSuccessResponse>(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
} catch (err: any) {
// The cached token may have expired server-side even though our
// conservative local TTL hasn't - retry once with a forced refresh
// before treating this as a real failure.
if (err?.response?.status === 401) {
const token = await getAccessToken(true);
const res = await axios.post<SalesforceSuccessResponse>(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
}
throw err;
}
};
const markSynced = async (signupId: number, externalId: string): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.query(
`UPDATE newsletter_signups SET sync_status = 'SENT', synced_at = NOW(), external_id = ?, sync_attempts = sync_attempts + 1, last_error = NULL WHERE signup_id = ?`,
[externalId, signupId]
);
} finally {
await conn.end();
}
};
const markFailed = async (signupId: number, errorMessage: string): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
await conn.query(
`UPDATE newsletter_signups SET sync_status = 'FAILED', last_error = ?, sync_attempts = sync_attempts + 1 WHERE signup_id = ?`,
[errorMessage.slice(0, 2000), signupId]
);
} finally {
await conn.end();
}
};
/**
* Reads one newsletter_signups row and syncs it to Salesforce. Always
* called after the owning submission's transaction has committed, never
* awaited by the request handler. When SALESFORCE_ENABLED is false, this
* only logs the payload it would have sent - the row's sync_status is
* already 'SKIPPED' from the insert in submissions.service.ts, so there's
* nothing to update.
*/
export const syncNewsletterSignup = async (signupId: number): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
let row: NewsletterSignupRow | undefined;
try {
const rows = await conn.query(
`SELECT ns.signup_id, ns.first_name, ns.last_name, ns.email, e.name AS event_name
FROM newsletter_signups ns JOIN events e ON e.event_id = ns.event_id
WHERE ns.signup_id = ?`,
[signupId]
);
row = rows[0];
} finally {
await conn.end();
}
if (!row) {
logger.error('syncNewsletterSignup: signup not found', {signupId});
return;
}
const payload = {firstName: row.first_name, lastName: row.last_name, email: row.email, eventName: row.event_name};
if (process.env.SALESFORCE_ENABLED !== 'true') {
logger.info('syncNewsletterSignup: SALESFORCE_ENABLED is false, would have sent', {signupId, payload});
return;
}
try {
const result = await postSignup(payload);
await markSynced(signupId, result.salesforceRecordId);
} catch (err: any) {
const message = err?.response?.data?.message || err?.message || 'Unknown error';
logger.error('syncNewsletterSignup failed', {signupId, message});
await markFailed(signupId, message);
}
};
@@ -0,0 +1,104 @@
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, poster_image_url
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,
posterImageUrl: row.poster_image_url
}));
} 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, poster_image_url, 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,
posterImageUrl: eventRow.poster_image_url,
introText: eventRow.intro_text,
songs,
questions
}
};
} finally {
await conn.end();
}
};
+193
View File
@@ -0,0 +1,193 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import logger from '../../../middleware/logger';
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service';
import {submitFeedback} from './submissions.service';
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
*/
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) {
sendServerError(res, e);
}
});
/**
* @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) {
sendServerError(res, e);
}
});
/**
* @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, newsletterDropped: false});
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, newsletterDropped: result.newsletterDropped});
return;
}
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,98 @@
/**
* @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
* newsletterDropped:
* type: boolean
* description: True if the newsletter opt-in was present but failed validation (e.g. a malformed email) - the rest of the submission still saved.
*/
export interface RatingInput {
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,229 @@
import {NachklangFeedbackDB} from '../Feedback.db';
import {QuestionType} from '../feedback.interface';
import {getEventConfigBySlug} from './events.public.service';
import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface';
import {syncNewsletterSignup} from '../integrations/salesforce.service';
import logger from '../../../middleware/logger';
// Bump when the privacy/consent copy shown next to the newsletter opt-in
// changes; recorded per-signup so a past consent's exact wording is provable.
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; newsletterDropped: boolean }
| { 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);
// body.newsletter is only sent at all when the visitor had the opt-in
// checkbox on (see FeedbackForm.tsx), so a present-but-invalid block
// (e.g. a mistyped email) is distinguishable from "didn't opt in" - the
// rest of the submission still saves, but the client can tell the
// visitor their newsletter signup specifically didn't go through.
const newsletterDropped = !!body.newsletter && !newsletter;
if (answerRows.length === 0 && !guestBook && !newsletter) {
return {status: 'EMPTY'};
}
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]);
}
let newsletterSignupId: number | null = null;
if (newsletter) {
// The signup is always persisted locally first, regardless of sync
// outcome - syncNewsletterSignup (fired after commit, below) is what
// actually talks to Salesforce and moves PENDING to SENT/FAILED.
const salesforceEnabled = process.env.SALESFORCE_ENABLED === 'true';
const nlQuery = `INSERT INTO newsletter_signups
(submission_id, event_id, first_name, last_name, email, consent_text_version, sync_status)
VALUES (?,?,?,?,?,?,?) RETURNING signup_id`;
const nlRes = await conn.query(nlQuery, [
submissionId, eventId, newsletter.firstName, newsletter.lastName, newsletter.email,
CONSENT_TEXT_VERSION, salesforceEnabled ? 'PENDING' : 'SKIPPED'
]);
newsletterSignupId = nlRes[0].signup_id;
}
await conn.commit();
if (newsletterSignupId !== null) {
const signupId = newsletterSignupId;
void syncNewsletterSignup(signupId).catch((err) => {
logger.error('syncNewsletterSignup threw outside its own error handling', {signupId, error: String(err)});
});
}
return {status: 'OK', submissionId, newsletterDropped};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};