Add Feedback domain module: public submission flow, admin CRUD, reporting #7

Open
Paddy wants to merge 9 commits from feature/feedback-module into master
18 changed files with 72 additions and 126 deletions
Showing only changes of commit c2ddb11c4c - Show all commits
+2 -9
View File
@@ -2,10 +2,9 @@
* Required External Modules and Interfaces * Required External Modules and Interfaces
*/ */
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger';
import {publicRouter} from './public/public.router'; import {publicRouter} from './public/public.router';
import {adminRouter} from './admin/admin.router'; import {adminRouter} from './admin/admin.router';
import {sendServerError} from './feedback.errors';
/** /**
* Router Definition * Router Definition
@@ -52,12 +51,6 @@ feedbackRouter.get('/', async (req: Request, res: Response) => {
try { try {
res.status(200).send('Nachklang e.V. Feedback API Endpoint'); res.status(200).send('Nachklang e.V. Feedback API Endpoint');
} catch (e: any) { } catch (e: any) {
let errorGuid = Guid.create().toString(); sendServerError(res, e);
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
});
} }
}); });
+2 -9
View File
@@ -2,9 +2,8 @@
* Required External Modules and Interfaces * Required External Modules and Interfaces
*/ */
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import {requireAdminAuth} from '../feedback.auth'; import {requireAdminAuth} from '../feedback.auth';
import {sendServerError} from '../feedback.errors';
import {eventsAdminRouter} from './events.admin.router'; import {eventsAdminRouter} from './events.admin.router';
import {songsAdminRouter} from './songs.admin.router'; import {songsAdminRouter} from './songs.admin.router';
import {questionsAdminRouter} from './questions.admin.router'; import {questionsAdminRouter} from './questions.admin.router';
@@ -81,13 +80,7 @@ adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Respo
} }
res.status(204).send(); res.status(204).send();
} catch (e: any) { } catch (e: any) {
let errorGuid = Guid.create().toString(); sendServerError(res, e);
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
});
} }
}); });
+1 -9
View File
@@ -1,4 +1,5 @@
import {NachklangFeedbackDB} from '../Feedback.db'; import {NachklangFeedbackDB} from '../Feedback.db';
import {formatDatetime} from '../feedback.dates';
const CSV_SEPARATOR = ';'; const CSV_SEPARATOR = ';';
const UTF8_BOM = ''; const UTF8_BOM = '';
@@ -23,15 +24,6 @@ export const escapeCsvField = (value: string | number | null | undefined): strin
return str; return str;
}; };
/** mariadb returns DATETIME columns as JS Date objects - format explicitly,
* otherwise String(date) falls back to the verbose Date.toString() format. */
export const formatDatetime = (value: Date | string | null): string => {
if (!value) return '';
const d = value instanceof Date ? value : new Date(value);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const buildCsv = (headers: string[], rows: (string | number | null | undefined)[][]): string => { const buildCsv = (headers: string[], rows: (string | number | null | undefined)[][]): string => {
const lines = [headers.map(escapeCsvField).join(CSV_SEPARATOR)]; const lines = [headers.map(escapeCsvField).join(CSV_SEPARATOR)];
for (const row of rows) { for (const row of rows) {
@@ -2,26 +2,15 @@
* Required External Modules and Interfaces * Required External Modules and Interfaces
*/ */
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import * as EventsAdminService from './events.admin.service'; import * as EventsAdminService from './events.admin.service';
import * as SongsAdminService from './songs.admin.service'; import * as SongsAdminService from './songs.admin.service';
import {sendServerError} from '../feedback.errors';
/** /**
* Router Definition * Router Definition
*/ */
export const eventsAdminRouter = express.Router(); export const eventsAdminRouter = express.Router();
const sendServerError = (res: Response, e: any) => {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
};
/** /**
* @swagger * @swagger
* /feedback/admin/events: * /feedback/admin/events:
@@ -1,6 +1,7 @@
import {NachklangFeedbackDB} from '../Feedback.db'; import {NachklangFeedbackDB} from '../Feedback.db';
import {Song} from '../feedback.interface'; import {Song} from '../feedback.interface';
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface'; import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface';
import {formatDatetime} from '../feedback.dates';
const UMLAUT_MAP: Record<string, string> = { const UMLAUT_MAP: Record<string, string> = {
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss', 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss',
@@ -83,11 +84,6 @@ const generateUniqueSlug = async (conn: any, name: string, eventDate: string): P
} }
}; };
const toMysqlDatetime = (d: Date): string => {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
export const createEvent = async (input: CreateEventInput, createdByEmail: string): Promise<number> => { export const createEvent = async (input: CreateEventInput, createdByEmail: string): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection(); let conn = await NachklangFeedbackDB.getConnection();
try { try {
@@ -101,7 +97,7 @@ export const createEvent = async (input: CreateEventInput, createdByEmail: strin
INSERT INTO events (slug, name, subtitle, event_date, feedback_deadline, intro_text, created_by_email) INSERT INTO events (slug, name, subtitle, event_date, feedback_deadline, intro_text, created_by_email)
VALUES (?,?,?,?,?,?,?) RETURNING event_id`; VALUES (?,?,?,?,?,?,?) RETURNING event_id`;
const res = await conn.query(query, [ const res = await conn.query(query, [
slug, input.name, input.subtitle || null, input.eventDate, toMysqlDatetime(deadline), slug, input.name, input.subtitle || null, input.eventDate, formatDatetime(deadline),
input.introText || null, createdByEmail input.introText || null, createdByEmail
]); ]);
await conn.commit(); await conn.commit();
@@ -2,25 +2,14 @@
* Required External Modules and Interfaces * Required External Modules and Interfaces
*/ */
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import * as QuestionsAdminService from './questions.admin.service'; import * as QuestionsAdminService from './questions.admin.service';
import {sendServerError} from '../feedback.errors';
/** /**
* Router Definition * Router Definition
*/ */
export const questionsAdminRouter = express.Router(); export const questionsAdminRouter = express.Router();
const sendServerError = (res: Response, e: any) => {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
};
/** /**
* @swagger * @swagger
* /feedback/admin/questions: * /feedback/admin/questions:
@@ -58,6 +58,7 @@ export interface EventReport {
sent: number; sent: number;
pending: number; pending: number;
failed: number; failed: number;
skipped: number;
}; };
} }
@@ -2,27 +2,16 @@
* Required External Modules and Interfaces * Required External Modules and Interfaces
*/ */
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import * as ReportsAdminService from './reports.admin.service'; import * as ReportsAdminService from './reports.admin.service';
import * as CsvService from './csv.service'; import * as CsvService from './csv.service';
import * as EventsAdminService from './events.admin.service'; import * as EventsAdminService from './events.admin.service';
import {sendServerError} from '../feedback.errors';
/** /**
* Router Definition * Router Definition
*/ */
export const reportsAdminRouter = express.Router(); export const reportsAdminRouter = express.Router();
const sendServerError = (res: Response, e: any) => {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
};
/** /**
* @swagger * @swagger
* /feedback/admin/events/{eventId}/report: * /feedback/admin/events/{eventId}/report:
@@ -16,7 +16,7 @@ export const aggregateReport = (
submissionStats: {totalSubmissions: number; firstSubmissionAt: string | null; lastSubmissionAt: string | null}, submissionStats: {totalSubmissions: number; firstSubmissionAt: string | null; lastSubmissionAt: string | null},
answerRows: AnswerRow[], answerRows: AnswerRow[],
guestBookCount: number, guestBookCount: number,
newsletterCounts: {total: number; sent: number; pending: number; failed: number} newsletterCounts: {total: number; sent: number; pending: number; failed: number; skipped: number}
): EventReport => { ): EventReport => {
const groupKey = (row: AnswerRow) => `${row.questionId ?? 'null'}::${row.questionLabel}`; const groupKey = (row: AnswerRow) => `${row.questionId ?? 'null'}::${row.questionLabel}`;
@@ -137,13 +137,14 @@ export const getReport = async (eventId: number): Promise<EventReport | null> =>
`SELECT sync_status, COUNT(*) as cnt FROM newsletter_signups WHERE event_id = ? GROUP BY sync_status`, `SELECT sync_status, COUNT(*) as cnt FROM newsletter_signups WHERE event_id = ? GROUP BY sync_status`,
[eventId] [eventId]
); );
const newsletterCounts = {total: 0, sent: 0, pending: 0, failed: 0}; const newsletterCounts = {total: 0, sent: 0, pending: 0, failed: 0, skipped: 0};
for (const row of newsletterRows) { for (const row of newsletterRows) {
const cnt = Number(row.cnt); const cnt = Number(row.cnt);
newsletterCounts.total += cnt; newsletterCounts.total += cnt;
if (row.sync_status === 'SENT') newsletterCounts.sent = cnt; if (row.sync_status === 'SENT') newsletterCounts.sent = cnt;
else if (row.sync_status === 'PENDING') newsletterCounts.pending = cnt; else if (row.sync_status === 'PENDING') newsletterCounts.pending = cnt;
else if (row.sync_status === 'FAILED') newsletterCounts.failed = cnt; else if (row.sync_status === 'FAILED') newsletterCounts.failed = cnt;
else if (row.sync_status === 'SKIPPED') newsletterCounts.skipped = cnt;
} }
return aggregateReport( return aggregateReport(
@@ -2,9 +2,8 @@
* Required External Modules and Interfaces * Required External Modules and Interfaces
*/ */
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import * as SongsAdminService from './songs.admin.service'; import * as SongsAdminService from './songs.admin.service';
import {sendServerError} from '../feedback.errors';
/** /**
* Router Definition * Router Definition
@@ -80,13 +79,7 @@ songsAdminRouter.put('/:songId', async (req: Request, res: Response) => {
} }
res.status(200).send({status: 'OK'}); res.status(200).send({status: 'OK'});
} catch (e: any) { } catch (e: any) {
let errorGuid = Guid.create().toString(); sendServerError(res, e);
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
});
} }
}); });
@@ -99,12 +92,6 @@ songsAdminRouter.delete('/:songId', async (req: Request, res: Response) => {
} }
res.status(204).send(); res.status(204).send();
} catch (e: any) { } catch (e: any) {
let errorGuid = Guid.create().toString(); sendServerError(res, e);
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
});
} }
}); });
+2 -9
View File
@@ -1,7 +1,6 @@
import express from 'express'; import express from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger';
import * as UserService from '../calendar/users/users.service'; 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 * This file is the ONLY place in the feedback module that knows how admin
@@ -74,12 +73,6 @@ export const requireAdminAuth: express.RequestHandler = async (req, res, next) =
res.locals.admin = identity; res.locals.admin = identity;
next(); next();
} catch (e: any) { } catch (e: any) {
let errorGuid = Guid.create().toString(); sendServerError(res, e);
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
});
} }
}; };
+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
});
};
+6 -24
View File
@@ -2,11 +2,11 @@
* Required External Modules and Interfaces * Required External Modules and Interfaces
*/ */
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger'; import logger from '../../../middleware/logger';
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service'; import {getEligibleEvents, getEventConfigBySlug} from './events.public.service';
import {submitFeedback} from './submissions.service'; import {submitFeedback} from './submissions.service';
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit'; import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit';
import {sendServerError} from '../feedback.errors';
/** /**
* Router Definition * Router Definition
@@ -51,13 +51,7 @@ publicRouter.get('/events', async (req: Request, res: Response) => {
const events = await getEligibleEvents(); const events = await getEligibleEvents();
res.status(200).send(events); res.status(200).send(events);
} catch (e: any) { } catch (e: any) {
let errorGuid = Guid.create().toString(); sendServerError(res, e);
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
});
} }
}); });
@@ -106,13 +100,7 @@ publicRouter.get('/events/:slug', async (req: Request, res: Response) => {
} }
res.status(200).send(result.event); res.status(200).send(result.event);
} catch (e: any) { } catch (e: any) {
let errorGuid = Guid.create().toString(); sendServerError(res, e);
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
});
} }
}); });
@@ -166,7 +154,7 @@ publicRouter.post('/events/:slug/submissions', async (req: Request, res: Respons
// nothing, stay silent about it having failed. // nothing, stay silent about it having failed.
if (isHoneypotTriggered(body)) { if (isHoneypotTriggered(body)) {
logger.info('Feedback honeypot triggered', {slug: req.params.slug}); logger.info('Feedback honeypot triggered', {slug: req.params.slug});
res.status(201).send({submissionId: -1}); res.status(201).send({submissionId: -1, newsletterDropped: false});
return; return;
} }
@@ -196,16 +184,10 @@ publicRouter.post('/events/:slug/submissions', async (req: Request, res: Respons
res.status(400).send({status: 'EMPTY_SUBMISSION'}); res.status(400).send({status: 'EMPTY_SUBMISSION'});
return; return;
case 'OK': case 'OK':
res.status(201).send({submissionId: result.submissionId}); res.status(201).send({submissionId: result.submissionId, newsletterDropped: result.newsletterDropped});
return; return;
} }
} catch (e: any) { } catch (e: any) {
let errorGuid = Guid.create().toString(); sendServerError(res, e);
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
});
} }
}); });
@@ -62,6 +62,9 @@
* submissionId: * submissionId:
* type: integer * type: integer
* example: 91 * 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 { export interface RatingInput {
@@ -137,7 +137,7 @@ export const validateNewsletter = (input?: NewsletterInput): ValidatedNewsletter
}; };
export type SubmitResult = export type SubmitResult =
| { status: 'OK'; submissionId: number } | { status: 'OK'; submissionId: number; newsletterDropped: boolean }
| { status: 'NOT_FOUND' } | { status: 'NOT_FOUND' }
| { status: 'CLOSED' } | { status: 'CLOSED' }
| { status: 'EMPTY' }; | { status: 'EMPTY' };
@@ -160,6 +160,12 @@ export const submitFeedback = async (slug: string, body: SubmissionRequestBody,
const answerRows = validateAnswers(body.answers || [], questionsById, songTitleById); const answerRows = validateAnswers(body.answers || [], questionsById, songTitleById);
const guestBook = validateGuestBook(body.guestBook); const guestBook = validateGuestBook(body.guestBook);
const newsletter = validateNewsletter(body.newsletter); 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) { if (answerRows.length === 0 && !guestBook && !newsletter) {
return {status: 'EMPTY'}; return {status: 'EMPTY'};
@@ -213,7 +219,7 @@ export const submitFeedback = async (slug: string, body: SubmissionRequestBody,
}); });
} }
return {status: 'OK', submissionId}; return {status: 'OK', submissionId, newsletterDropped};
} catch (err) { } catch (err) {
await conn.rollback(); await conn.rollback();
throw err; throw err;
+2 -1
View File
@@ -1,4 +1,5 @@
import {escapeCsvField, formatDatetime} from '../../src/models/feedback/admin/csv.service'; import {escapeCsvField} from '../../src/models/feedback/admin/csv.service';
import {formatDatetime} from '../../src/models/feedback/feedback.dates';
describe('escapeCsvField', () => { describe('escapeCsvField', () => {
it('passes plain text through unchanged', () => { it('passes plain text through unchanged', () => {
+3 -3
View File
@@ -2,7 +2,7 @@ import {aggregateReport} from '../../src/models/feedback/admin/reports.admin.ser
import {AnswerRow} from '../../src/models/feedback/admin/reports.admin.interface'; import {AnswerRow} from '../../src/models/feedback/admin/reports.admin.interface';
const eventMeta = {eventId: 1, name: 'Sommerkonzert', eventDate: '2026-08-01', feedbackDeadline: '2026-08-15T23:59:59'}; const eventMeta = {eventId: 1, name: 'Sommerkonzert', eventDate: '2026-08-01', feedbackDeadline: '2026-08-15T23:59:59'};
const emptyNewsletter = {total: 0, sent: 0, pending: 0, failed: 0}; const emptyNewsletter = {total: 0, sent: 0, pending: 0, failed: 0, skipped: 0};
const row = (overrides: Partial<AnswerRow>): AnswerRow => ({ const row = (overrides: Partial<AnswerRow>): AnswerRow => ({
submissionId: 1, submissionId: 1,
@@ -90,13 +90,13 @@ describe('aggregateReport - top-level fields', () => {
{totalSubmissions: 42, firstSubmissionAt: '2026-08-02T10:00:00.000Z', lastSubmissionAt: '2026-08-10T18:00:00.000Z'}, {totalSubmissions: 42, firstSubmissionAt: '2026-08-02T10:00:00.000Z', lastSubmissionAt: '2026-08-10T18:00:00.000Z'},
[], [],
7, 7,
{total: 10, sent: 6, pending: 2, failed: 2} {total: 10, sent: 6, pending: 2, failed: 1, skipped: 1}
); );
expect(report.totalSubmissions).toBe(42); expect(report.totalSubmissions).toBe(42);
expect(report.firstSubmissionAt).toBe('2026-08-02T10:00:00.000Z'); expect(report.firstSubmissionAt).toBe('2026-08-02T10:00:00.000Z');
expect(report.lastSubmissionAt).toBe('2026-08-10T18:00:00.000Z'); expect(report.lastSubmissionAt).toBe('2026-08-10T18:00:00.000Z');
expect(report.guestBookCount).toBe(7); expect(report.guestBookCount).toBe(7);
expect(report.newsletter).toEqual({total: 10, sent: 6, pending: 2, failed: 2}); expect(report.newsletter).toEqual({total: 10, sent: 6, pending: 2, failed: 1, skipped: 1});
expect(report.event).toEqual(eventMeta); expect(report.event).toEqual(eventMeta);
}); });