c2ddb11c4c
Three fixes from the earlier review, plus cleanup: - submissions.service.ts: a newsletter opt-in present but failing validation (e.g. malformed email) was silently dropped with no signal to the client - the rest of the submission saved, but the visitor had no way to know their newsletter signup didn't go through. Added newsletterDropped to the submit response so the frontend can tell them. - reports.admin.service.ts: the newsletter summary tracked total/sent/pending/failed but silently omitted SKIPPED (stub-mode) signups from any bucket - every current signup showed total>0 with every bucket reading 0, indistinguishable from "we don't know what happened". Added a skipped count. - Consolidated two things duplicated across the module: sendServerError (reimplemented ~11 times, three of those as identical local copies of the same function) into feedback.errors.ts, and formatDatetime/ toMysqlDatetime (the same local-time formatting logic under two names, in csv.service.ts and events.admin.service.ts respectively) into feedback.dates.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
223 lines
7.6 KiB
TypeScript
223 lines
7.6 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
});
|