From 56074d4441ad5b553953b14b8bc6a29e6784deb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20M=C3=BCller?= Date: Thu, 6 Aug 2026 22:55:32 +0200 Subject: [PATCH] Allow dev CORS from LAN IPs; add submission deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev-only CORS bypass in app.ts only ever matched http://localhost:, never the LAN IP a phone actually connects through over WiFi - so testing the feedback form from a real device against a local dev API had its submissions silently rejected by CORS. Extended the bypass to also allow private LAN ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x), dev-only as before. Also adds DELETE /feedback/admin/submissions/:submissionId (cascades to the submission's answers, guest book entry, and newsletter signup in explicit dependency order, single-path by submission_id) so an admin can remove an individual abusive/inappropriate entry - decided in IMPLEMENTATION_PLAN.md ยง7 item 8. getGuestBookEntries now also returns submissionId so the admin UI can target the delete call. Co-Authored-By: Claude Sonnet 5 --- app.ts | 11 +++-- src/models/feedback/admin/admin.router.ts | 45 +++++++++++++++++++ .../feedback/admin/reports.admin.service.ts | 44 +++++++++++++++++- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/app.ts b/app.ts index 0d70d99..4160e6a 100644 --- a/app.ts +++ b/app.ts @@ -40,15 +40,20 @@ let allowedHosts = [ ]; const isDev = process.env.NODE_ENV !== 'production'; const localhostRegex = /^http:\/\/localhost:\d+$/; +// Matches http://: - needed so the feedback form can +// be reached from a real phone over WiFi during dev (the phone's Origin is +// the dev machine's LAN IP, never "localhost"). Dev-only, same as above. +const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/; app.use(cors({ allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'], origin: function (origin: any, callback: any) { // Allow requests with no origin if (!origin) return callback(null, true); - // Any localhost port is fine outside production - dev servers pick - // whatever port is free (Next.js falls back from 3000 if it's taken). - if (isDev && localhostRegex.test(origin)) { + // Any localhost port, or a private-LAN IP, is fine outside production - + // dev servers pick whatever port is free (Next.js falls back from 3000 + // if it's taken), and real-device testing hits the dev machine by IP. + if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) { return callback(null, true); } diff --git a/src/models/feedback/admin/admin.router.ts b/src/models/feedback/admin/admin.router.ts index e0f7977..abf2a08 100644 --- a/src/models/feedback/admin/admin.router.ts +++ b/src/models/feedback/admin/admin.router.ts @@ -2,11 +2,14 @@ * Required External Modules and Interfaces */ import express, {Request, Response} from 'express'; +import {Guid} from 'guid-typescript'; +import logger from '../../../middleware/logger'; import {requireAdminAuth} from '../feedback.auth'; import {eventsAdminRouter} from './events.admin.router'; import {songsAdminRouter} from './songs.admin.router'; import {questionsAdminRouter} from './questions.admin.router'; import {reportsAdminRouter} from './reports.admin.router'; +import * as ReportsAdminService from './reports.admin.service'; /** * Router Definition @@ -46,6 +49,48 @@ adminRouter.get('/me', (req: Request, res: Response) => { res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName}); }); +/** + * @swagger + * /feedback/admin/submissions/{submissionId}: + * delete: + * summary: Delete a single submission + * description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool. + * tags: [feedback-admin] + * parameters: + * - $ref: '#/components/parameters/SessionIdHeader' + * - $ref: '#/components/parameters/SessionKeyHeader' + * - in: path + * name: submissionId + * required: true + * schema: + * type: integer + * responses: + * 204: + * description: Deleted + * 404: + * description: Unknown submission + * 401: + * description: Unauthorized + */ +adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => { + try { + const deleted = await ReportsAdminService.deleteSubmission(Number(req.params.submissionId)); + if (!deleted) { + res.status(404).send({status: 'NOT_FOUND'}); + return; + } + res.status(204).send(); + } catch (e: any) { + let errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + status: 'PROCESSING_ERROR', + message: 'Internal Server Error. Try again later.', + reference: errorGuid + }); + } +}); + adminRouter.use('/events', eventsAdminRouter); adminRouter.use('/events', reportsAdminRouter); adminRouter.use('/songs', songsAdminRouter); diff --git a/src/models/feedback/admin/reports.admin.service.ts b/src/models/feedback/admin/reports.admin.service.ts index 34dec6c..4042561 100644 --- a/src/models/feedback/admin/reports.admin.service.ts +++ b/src/models/feedback/admin/reports.admin.service.ts @@ -160,6 +160,7 @@ export const getReport = async (eventId: number): Promise => export interface GuestBookEntry { entryId: number; + submissionId: number; submittedAt: string; displayName: string | null; message: string | null; @@ -170,12 +171,18 @@ export const getGuestBookEntries = async (eventId: number, page: number, pageSiz try { const totalRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]); const rows = await conn.query( - 'SELECT entry_id, created_at, display_name, message FROM guest_book_entries WHERE event_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', + 'SELECT entry_id, submission_id, created_at, display_name, message FROM guest_book_entries WHERE event_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', [eventId, pageSize, (page - 1) * pageSize] ); return { total: Number(totalRows[0].cnt), - entries: rows.map((r: any) => ({entryId: r.entry_id, submittedAt: r.created_at, displayName: r.display_name, message: r.message})) + entries: rows.map((r: any) => ({ + entryId: r.entry_id, + submissionId: r.submission_id, + submittedAt: r.created_at, + displayName: r.display_name, + message: r.message + })) }; } finally { await conn.end(); @@ -192,6 +199,39 @@ export interface NewsletterSignupRow { lastError: string | null; } +/** + * Deletes one submission and everything under it (its answers, guest book + * entry, newsletter signup). Single-path deletes by submission_id - unlike + * deleteEvent's multi-path cascade issue, there's only one way to reach each + * child table here, so explicit ordering is for consistency with that + * function's style, not to work around an ambiguous-cascade error. + */ +export const deleteSubmission = async (submissionId: number): Promise => { + let conn = await NachklangFeedbackDB.getConnection(); + try { + await conn.beginTransaction(); + + const rows = await conn.query('SELECT submission_id FROM submissions WHERE submission_id = ?', [submissionId]); + if (rows.length === 0) { + await conn.rollback(); + return false; + } + + await conn.query('DELETE FROM guest_book_entries WHERE submission_id = ?', [submissionId]); + await conn.query('DELETE FROM newsletter_signups WHERE submission_id = ?', [submissionId]); + await conn.query('DELETE FROM submission_answers WHERE submission_id = ?', [submissionId]); + await conn.query('DELETE FROM submissions WHERE submission_id = ?', [submissionId]); + + await conn.commit(); + return true; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + await conn.end(); + } +}; + export const getNewsletterSignups = async (eventId: number): Promise => { let conn = await NachklangFeedbackDB.getConnection(); try {