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

Open
Paddy wants to merge 9 commits from feature/feedback-module into master
3 changed files with 95 additions and 5 deletions
Showing only changes of commit 56074d4441 - Show all commits
+8 -3
View File
@@ -40,15 +40,20 @@ let allowedHosts = [
]; ];
const isDev = process.env.NODE_ENV !== 'production'; const isDev = process.env.NODE_ENV !== 'production';
const localhostRegex = /^http:\/\/localhost:\d+$/; const localhostRegex = /^http:\/\/localhost:\d+$/;
// Matches http://<private-LAN-IPv4>:<port> - 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({ app.use(cors({
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'], allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
origin: function (origin: any, callback: any) { origin: function (origin: any, callback: any) {
// Allow requests with no origin // Allow requests with no origin
if (!origin) return callback(null, true); if (!origin) return callback(null, true);
// Any localhost port is fine outside production - dev servers pick // Any localhost port, or a private-LAN IP, is fine outside production -
// whatever port is free (Next.js falls back from 3000 if it's taken). // dev servers pick whatever port is free (Next.js falls back from 3000
if (isDev && localhostRegex.test(origin)) { // 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); return callback(null, true);
} }
+45
View File
@@ -2,11 +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 {requireAdminAuth} from '../feedback.auth'; import {requireAdminAuth} from '../feedback.auth';
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';
import {reportsAdminRouter} from './reports.admin.router'; import {reportsAdminRouter} from './reports.admin.router';
import * as ReportsAdminService from './reports.admin.service';
/** /**
* Router Definition * 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}); 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', eventsAdminRouter);
adminRouter.use('/events', reportsAdminRouter); adminRouter.use('/events', reportsAdminRouter);
adminRouter.use('/songs', songsAdminRouter); adminRouter.use('/songs', songsAdminRouter);
@@ -160,6 +160,7 @@ export const getReport = async (eventId: number): Promise<EventReport | null> =>
export interface GuestBookEntry { export interface GuestBookEntry {
entryId: number; entryId: number;
submissionId: number;
submittedAt: string; submittedAt: string;
displayName: string | null; displayName: string | null;
message: string | null; message: string | null;
@@ -170,12 +171,18 @@ export const getGuestBookEntries = async (eventId: number, page: number, pageSiz
try { try {
const totalRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]); const totalRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]);
const rows = await conn.query( 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] [eventId, pageSize, (page - 1) * pageSize]
); );
return { return {
total: Number(totalRows[0].cnt), 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 { } finally {
await conn.end(); await conn.end();
@@ -192,6 +199,39 @@ export interface NewsletterSignupRow {
lastError: string | null; 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): Promise<NewsletterSignupRow[]> => { export const getNewsletterSignups = async (eventId: number): Promise<NewsletterSignupRow[]> => {
let conn = await NachklangFeedbackDB.getConnection(); let conn = await NachklangFeedbackDB.getConnection();
try { try {