3 Commits

Author SHA1 Message Date
Paddy 01a914f31e Paginate and add search to the newsletter signups endpoint
Unlike guest book and free-text, newsletter signups had no LIMIT at all and the frontend rendered every row in one plain table. Newsletter opt-in is a single checkbox rather than typed text, so it's plausibly the largest per-event list - fix it the same way as guest book: paginated (page/pageSize, capped at 200/page) plus an optional ?search= over first name, last name, and email.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 21:49:00 +02:00
Paddy 2988a70d8f Add search to the admin guest book endpoint
At scale (many submissions after a concert), paging through the guest book 20 entries at a time with no way to find a specific person is impractical. Add an optional ?search= query param that filters entries whose name or message contains the term (case-insensitive), with LIKE wildcards escaped so a literal % or _ in a search term can't be misinterpreted as a pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 21:32:32 +02:00
Paddy 07d757e9af Remove hardcoded USE statement from feedback schema migration
001_init.sql opened with USE `nachklang-feedback`, contradicting its own
documented apply instructions (mysql ... <FEEDBACK_DB> < 001_init.sql,
which already selects the database via the command line). Following the
file's own usage note literally would fail unless a database happened to
be named exactly nachklang-feedback rather than whatever FEEDBACK_DB is
configured to in .env.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 21:02:47 +02:00
3 changed files with 73 additions and 16 deletions
+4 -2
View File
@@ -6,8 +6,10 @@
-- --
-- Apply with e.g.: -- Apply with e.g.:
-- mysql -h <DB_HOST> -u <DB_USER> -p <FEEDBACK_DB> < 001_init.sql -- mysql -h <DB_HOST> -u <DB_USER> -p <FEEDBACK_DB> < 001_init.sql
--
USE `nachklang-feedback`; -- Deliberately no USE statement here: the target database is selected via
-- the mysql command line above (whatever FEEDBACK_DB is actually named in
-- .env), not hardcoded to a literal schema name.
-- 1. events ------------------------------------------------------------- -- 1. events -------------------------------------------------------------
CREATE TABLE events ( CREATE TABLE events (
@@ -82,6 +82,11 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) =
* name: pageSize * name: pageSize
* schema: * schema:
* type: integer * type: integer
* - in: query
* name: search
* description: Filters entries whose name or message contains this text (case-insensitive).
* schema:
* type: string
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -92,7 +97,8 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
try { try {
const page = Math.max(1, Number(req.query.page) || 1); const page = Math.max(1, Number(req.query.page) || 1);
const pageSize = Math.min(200, Math.max(1, Number(req.query.pageSize) || 50)); const pageSize = Math.min(200, Math.max(1, Number(req.query.pageSize) || 50));
const result = await ReportsAdminService.getGuestBookEntries(Number(req.params.eventId), page, pageSize); const search = typeof req.query.search === 'string' ? req.query.search : undefined;
const result = await ReportsAdminService.getGuestBookEntries(Number(req.params.eventId), page, pageSize, search);
res.status(200).send(result); res.status(200).send(result);
} catch (e: any) { } catch (e: any) {
sendServerError(res, e); sendServerError(res, e);
@@ -104,7 +110,7 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
* /feedback/admin/events/{eventId}/newsletter: * /feedback/admin/events/{eventId}/newsletter:
* get: * get:
* summary: Newsletter signups for one event * summary: Newsletter signups for one event
* description: Includes sync_status, so failures can be handled manually. * description: Includes sync_status, so failures can be handled manually. Newest first, paginated.
* tags: [feedback-admin] * tags: [feedback-admin]
* parameters: * parameters:
* - $ref: '#/components/parameters/SessionIdHeader' * - $ref: '#/components/parameters/SessionIdHeader'
@@ -114,6 +120,19 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
* required: true * required: true
* schema: * schema:
* type: integer * 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: * responses:
* 200: * 200:
* description: Success * description: Success
@@ -122,7 +141,11 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
*/ */
reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => { reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => {
try { try {
res.status(200).send(await ReportsAdminService.getNewsletterSignups(Number(req.params.eventId))); const page = Math.max(1, Number(req.query.page) || 1);
const pageSize = Math.min(200, Math.max(1, Number(req.query.pageSize) || 50));
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
const result = await ReportsAdminService.getNewsletterSignups(Number(req.params.eventId), page, pageSize, search);
res.status(200).send(result);
} catch (e: any) { } catch (e: any) {
sendServerError(res, e); sendServerError(res, e);
} }
@@ -166,13 +166,29 @@ export interface GuestBookEntry {
message: string | null; message: string | null;
} }
export const getGuestBookEntries = async (eventId: number, page: number, pageSize: number): Promise<{entries: GuestBookEntry[]; total: number}> => { // Escapes LIKE wildcards (% and _) so a search term is matched literally,
// not interpreted as a pattern - a search for "50%" must not match everything.
const escapeLikeTerm = (term: string) => term.replace(/[\\%_]/g, (c) => `\\${c}`);
export const getGuestBookEntries = async (
eventId: number,
page: number,
pageSize: number,
search?: string
): Promise<{entries: GuestBookEntry[]; total: number}> => {
let conn = await NachklangFeedbackDB.getConnection(); let conn = await NachklangFeedbackDB.getConnection();
try { try {
const totalRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]); const trimmedSearch = search?.trim();
const whereClause = trimmedSearch
? 'WHERE event_id = ? AND (display_name LIKE ? ESCAPE \'\\\\\' OR message LIKE ? ESCAPE \'\\\\\')'
: 'WHERE event_id = ?';
const likeParam = trimmedSearch ? `%${escapeLikeTerm(trimmedSearch)}%` : undefined;
const whereParams = trimmedSearch ? [eventId, likeParam, likeParam] : [eventId];
const totalRows = await conn.query(`SELECT COUNT(*) as cnt FROM guest_book_entries ${whereClause}`, whereParams);
const rows = await conn.query( const rows = await conn.query(
'SELECT entry_id, submission_id, created_at, display_name, message FROM guest_book_entries WHERE event_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', `SELECT entry_id, submission_id, created_at, display_name, message FROM guest_book_entries ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[eventId, pageSize, (page - 1) * pageSize] [...whereParams, pageSize, (page - 1) * pageSize]
); );
return { return {
total: Number(totalRows[0].cnt), total: Number(totalRows[0].cnt),
@@ -232,17 +248,33 @@ export const deleteSubmission = async (submissionId: number): Promise<boolean> =
} }
}; };
export const getNewsletterSignups = async (eventId: number): Promise<NewsletterSignupRow[]> => { export const getNewsletterSignups = async (
eventId: number,
page: number,
pageSize: number,
search?: string
): Promise<{entries: NewsletterSignupRow[]; total: number}> => {
let conn = await NachklangFeedbackDB.getConnection(); let conn = await NachklangFeedbackDB.getConnection();
try { 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( const rows = await conn.query(
'SELECT signup_id, first_name, last_name, email, consent_at, sync_status, last_error FROM newsletter_signups WHERE event_id = ? ORDER BY consent_at DESC', `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 ?`,
[eventId] [...whereParams, pageSize, (page - 1) * pageSize]
); );
return rows.map((r: any) => ({ 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, 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 consentAt: r.consent_at, syncStatus: r.sync_status, lastError: r.last_error
})); }))
};
} finally { } finally {
await conn.end(); await conn.end();
} }