Add Feedback domain module: public submission flow, admin CRUD, reporting (#7)
Jenkins Production Deployment
Jenkins Production Deployment
Co-authored-by: Patrick Müller <mail@pmueller.me> Reviewed-on: #7 Co-authored-by: Patrick Mueller <patrick@mueller-patrick.tech> Co-committed-by: Patrick Mueller <patrick@mueller-patrick.tech>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* parameters:
|
||||
* SessionIdHeader:
|
||||
* in: header
|
||||
* name: X-Session-Id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* SessionKeyHeader:
|
||||
* in: header
|
||||
* name: X-Session-Key
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* schemas:
|
||||
* EventAdminSummary:
|
||||
* type: object
|
||||
* properties:
|
||||
* eventId:
|
||||
* type: integer
|
||||
* slug:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* subtitle:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* eventDate:
|
||||
* type: string
|
||||
* format: date
|
||||
* feedbackDeadline:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* posterImageUrl:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* isPublished:
|
||||
* type: boolean
|
||||
* submissionCount:
|
||||
* type: integer
|
||||
* EventAdminDetail:
|
||||
* allOf:
|
||||
* - $ref: '#/components/schemas/EventAdminSummary'
|
||||
* - type: object
|
||||
* properties:
|
||||
* introText:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* songs:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Song'
|
||||
* questions:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* eventQuestionId:
|
||||
* type: integer
|
||||
* questionId:
|
||||
* type: integer
|
||||
* position:
|
||||
* type: integer
|
||||
* isActive:
|
||||
* type: boolean
|
||||
* AdminQuestion:
|
||||
* type: object
|
||||
* properties:
|
||||
* questionId:
|
||||
* type: integer
|
||||
* label:
|
||||
* type: string
|
||||
* helpText:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* questionType:
|
||||
* $ref: '#/components/schemas/QuestionType'
|
||||
* isArchived:
|
||||
* type: boolean
|
||||
*/
|
||||
|
||||
import {QuestionType, Song} from '../feedback.interface';
|
||||
|
||||
export interface EventAdminSummary {
|
||||
eventId: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
subtitle: string | null;
|
||||
eventDate: string;
|
||||
feedbackDeadline: string;
|
||||
posterImageUrl: string | null;
|
||||
isPublished: boolean;
|
||||
submissionCount: number;
|
||||
}
|
||||
|
||||
export interface EventAdminQuestionAssignment {
|
||||
eventQuestionId: number;
|
||||
questionId: number;
|
||||
position: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface EventAdminDetail extends EventAdminSummary {
|
||||
introText: string | null;
|
||||
songs: Song[];
|
||||
questions: EventAdminQuestionAssignment[];
|
||||
}
|
||||
|
||||
export interface CreateEventInput {
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
eventDate: string;
|
||||
feedbackDeadline?: string;
|
||||
introText?: string;
|
||||
posterImageUrl?: string;
|
||||
}
|
||||
|
||||
export interface UpdateEventInput {
|
||||
name?: string;
|
||||
subtitle?: string;
|
||||
eventDate?: string;
|
||||
feedbackDeadline?: string;
|
||||
isPublished?: boolean;
|
||||
introText?: string;
|
||||
posterImageUrl?: string;
|
||||
}
|
||||
|
||||
export interface AdminQuestion {
|
||||
questionId: number;
|
||||
label: string;
|
||||
helpText: string | null;
|
||||
questionType: QuestionType;
|
||||
isArchived: boolean;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Required External Modules and Interfaces
|
||||
*/
|
||||
import express, {Request, Response} from 'express';
|
||||
import {requireAdminAuth} from '../feedback.auth';
|
||||
import {sendServerError} from '../feedback.errors';
|
||||
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
|
||||
*/
|
||||
export const adminRouter = express.Router();
|
||||
|
||||
// Applied once at the top of the admin router tree - every route below
|
||||
// requires a valid admin session.
|
||||
adminRouter.use(requireAdminAuth);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/admin/me:
|
||||
* get:
|
||||
* summary: Validate the current admin session
|
||||
* description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* fullName:
|
||||
* type: string
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
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) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
adminRouter.use('/events', eventsAdminRouter);
|
||||
adminRouter.use('/events', reportsAdminRouter);
|
||||
adminRouter.use('/songs', songsAdminRouter);
|
||||
adminRouter.use('/questions', questionsAdminRouter);
|
||||
@@ -0,0 +1,70 @@
|
||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
||||
import {formatDatetime} from '../feedback.dates';
|
||||
|
||||
const CSV_SEPARATOR = ';';
|
||||
const UTF8_BOM = '';
|
||||
|
||||
/**
|
||||
* RFC 4180 field escaping for a `;`-separated CSV, plus a formula-injection
|
||||
* guard: a field starting with = + - @ gets a leading apostrophe so
|
||||
* German-locale Excel never evaluates it as a formula.
|
||||
*/
|
||||
export const escapeCsvField = (value: string | number | null | undefined): string => {
|
||||
let str = value === null || value === undefined ? '' : String(value);
|
||||
str = str.replace(/\r\n|\r|\n/g, ' ');
|
||||
|
||||
if (/^[=+\-@]/.test(str)) {
|
||||
str = `'${str}`;
|
||||
}
|
||||
|
||||
if (str.includes(CSV_SEPARATOR) || str.includes('"')) {
|
||||
str = `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
|
||||
const buildCsv = (headers: string[], rows: (string | number | null | undefined)[][]): string => {
|
||||
const lines = [headers.map(escapeCsvField).join(CSV_SEPARATOR)];
|
||||
for (const row of rows) {
|
||||
lines.push(row.map(escapeCsvField).join(CSV_SEPARATOR));
|
||||
}
|
||||
return UTF8_BOM + lines.join('\r\n');
|
||||
};
|
||||
|
||||
export const buildResponsesCsv = async (eventId: number): Promise<string> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
const rows = await conn.query(
|
||||
`SELECT sa.submission_id, s.submitted_at, sa.question_label_snapshot, sa.question_type,
|
||||
sa.song_title_snapshot, sa.rating, sa.text_answer
|
||||
FROM submission_answers sa
|
||||
INNER JOIN submissions s ON s.submission_id = sa.submission_id
|
||||
WHERE sa.event_id = ?
|
||||
ORDER BY sa.submission_id ASC`,
|
||||
[eventId]
|
||||
);
|
||||
return buildCsv(
|
||||
['submission_id', 'submitted_at', 'question_label', 'question_type', 'song_title', 'rating', 'text_answer'],
|
||||
rows.map((r: any) => [r.submission_id, formatDatetime(r.submitted_at), r.question_label_snapshot, r.question_type, r.song_title_snapshot, r.rating, r.text_answer])
|
||||
);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export const buildGuestBookCsv = async (eventId: number): Promise<string> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
const rows = await conn.query(
|
||||
'SELECT entry_id, created_at, display_name, message FROM guest_book_entries WHERE event_id = ? ORDER BY created_at ASC',
|
||||
[eventId]
|
||||
);
|
||||
return buildCsv(
|
||||
['entry_id', 'submitted_at', 'display_name', 'message'],
|
||||
rows.map((r: any) => [r.entry_id, formatDatetime(r.created_at), r.display_name, r.message])
|
||||
);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Required External Modules and Interfaces
|
||||
*/
|
||||
import express, {Request, Response} from 'express';
|
||||
import * as EventsAdminService from './events.admin.service';
|
||||
import * as SongsAdminService from './songs.admin.service';
|
||||
import {sendServerError} from '../feedback.errors';
|
||||
|
||||
/**
|
||||
* Router Definition
|
||||
*/
|
||||
export const eventsAdminRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/admin/events:
|
||||
* get:
|
||||
* summary: List all events (admin)
|
||||
* description: All events, published or not, past or future, with submission counts.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/EventAdminSummary'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* post:
|
||||
* summary: Create an event
|
||||
* description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [name, eventDate]
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* subtitle:
|
||||
* type: string
|
||||
* eventDate:
|
||||
* type: string
|
||||
* format: date
|
||||
* feedbackDeadline:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* introText:
|
||||
* type: string
|
||||
* posterImageUrl:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created
|
||||
* 400:
|
||||
* description: Missing required fields
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.status(200).send(await EventsAdminService.listEventsAdmin());
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
eventsAdminRouter.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl} = req.body || {};
|
||||
if (!name || !eventDate) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'name and eventDate are required'});
|
||||
return;
|
||||
}
|
||||
const eventId = await EventsAdminService.createEvent(
|
||||
{name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl},
|
||||
res.locals.admin.email
|
||||
);
|
||||
res.status(201).send({eventId});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/admin/events/{eventId}:
|
||||
* get:
|
||||
* summary: Get one event (admin)
|
||||
* description: Full event detail including setlist and assigned questions.
|
||||
* 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
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/EventAdminDetail'
|
||||
* 404:
|
||||
* description: Unknown event
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* put:
|
||||
* summary: Update an 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: Updated
|
||||
* 404:
|
||||
* description: Unknown event
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* delete:
|
||||
* summary: Delete an event
|
||||
* description: Refuses with 409 if submissions exist unless ?force=true is passed.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: force
|
||||
* schema:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Deleted
|
||||
* 404:
|
||||
* description: Unknown event
|
||||
* 409:
|
||||
* description: Submissions exist and force was not set
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
|
||||
if (!event) {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send(event);
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
eventsAdminRouter.put('/:eventId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const updated = await EventsAdminService.updateEvent(Number(req.params.eventId), req.body || {});
|
||||
if (!updated) {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send({status: 'OK'});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const force = req.query.force === 'true';
|
||||
const result = await EventsAdminService.deleteEvent(Number(req.params.eventId), force);
|
||||
if (result === 'NOT_FOUND') {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
if (result === 'HAS_SUBMISSIONS') {
|
||||
res.status(409).send({status: 'HAS_SUBMISSIONS', message: 'This event has submissions. Pass ?force=true to delete anyway.'});
|
||||
return;
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/admin/events/{eventId}/songs:
|
||||
* get:
|
||||
* summary: Get an event's setlist
|
||||
* 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
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* post:
|
||||
* summary: Add a song to an event's setlist
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [title]
|
||||
* properties:
|
||||
* title:
|
||||
* type: string
|
||||
* composer:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created
|
||||
* 400:
|
||||
* description: Missing title
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
|
||||
if (!event) {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send(event.songs);
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {title, composer} = req.body || {};
|
||||
if (!title) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'title is required'});
|
||||
return;
|
||||
}
|
||||
const songId = await SongsAdminService.addSong(Number(req.params.eventId), title, composer || null);
|
||||
res.status(201).send({songId});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/admin/events/{eventId}/songs/order:
|
||||
* put:
|
||||
* summary: Bulk reorder an event's setlist
|
||||
* description: Rewrites song positions as a dense 0..n-1 sequence in one transaction.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [songIds]
|
||||
* properties:
|
||||
* songIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Reordered
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const songIds: number[] = req.body?.songIds || [];
|
||||
await EventsAdminService.reorderSongs(Number(req.params.eventId), songIds);
|
||||
res.status(200).send({status: 'OK'});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/admin/events/{eventId}/questions:
|
||||
* get:
|
||||
* summary: Get an event's assigned questions
|
||||
* 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
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* put:
|
||||
* summary: Bulk-set an event's assigned questions
|
||||
* description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: eventId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [items]
|
||||
* properties:
|
||||
* items:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* questionId:
|
||||
* type: integer
|
||||
* position:
|
||||
* type: integer
|
||||
* isActive:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const event = await EventsAdminService.getEventAdmin(Number(req.params.eventId));
|
||||
if (!event) {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send(event.questions);
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
eventsAdminRouter.put('/:eventId/questions', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const items = req.body?.items || [];
|
||||
await EventsAdminService.setEventQuestions(Number(req.params.eventId), items);
|
||||
res.status(200).send({status: 'OK'});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
||||
import {Song} from '../feedback.interface';
|
||||
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface';
|
||||
import {formatDatetime} from '../feedback.dates';
|
||||
|
||||
const UMLAUT_MAP: Record<string, string> = {
|
||||
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss',
|
||||
'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue'
|
||||
};
|
||||
|
||||
/**
|
||||
* Slug base from a name: lowercase, umlaut-transliterated, hyphenated.
|
||||
* The caller appends the concert year and resolves collisions.
|
||||
*/
|
||||
export const slugifyName = (name: string): string => {
|
||||
const transliterated = name.replace(/[äöüßÄÖÜ]/g, (ch) => UMLAUT_MAP[ch] || ch);
|
||||
return transliterated
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Default feedback deadline: event day + 14 days, end of day. Computed
|
||||
* here (not by the DB) so the admin UI can pre-fill and override it.
|
||||
*/
|
||||
export const computeDefaultDeadline = (eventDateIso: string): Date => {
|
||||
const [year, month, day] = eventDateIso.split('-').map(Number);
|
||||
return new Date(year, month - 1, day + 14, 23, 59, 59);
|
||||
};
|
||||
|
||||
const mapSummaryRow = (row: any): EventAdminSummary => ({
|
||||
eventId: row.event_id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
subtitle: row.subtitle,
|
||||
eventDate: row.event_date,
|
||||
feedbackDeadline: row.feedback_deadline,
|
||||
posterImageUrl: row.poster_image_url,
|
||||
isPublished: !!row.is_published,
|
||||
submissionCount: Number(row.submission_count)
|
||||
});
|
||||
|
||||
export const listEventsAdmin = async (): Promise<EventAdminSummary[]> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
const query = `
|
||||
SELECT e.event_id, e.slug, e.name, e.subtitle, e.event_date, e.feedback_deadline, e.is_published,
|
||||
COUNT(s.submission_id) as submission_count
|
||||
FROM events e
|
||||
LEFT JOIN submissions s ON s.event_id = e.event_id
|
||||
GROUP BY e.event_id
|
||||
ORDER BY e.event_date DESC`;
|
||||
const rows = await conn.query(query);
|
||||
return rows.map(mapSummaryRow);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Slug base with the concert year appended as a disambiguator - unless the
|
||||
* name already ends with it (e.g. "Adventskonzert 2026"), which would
|
||||
* otherwise double up as "adventskonzert-2026-2026".
|
||||
*/
|
||||
export const slugBase = (name: string, eventDateIso: string): string => {
|
||||
const year = eventDateIso.split('-')[0];
|
||||
const nameSlug = slugifyName(name);
|
||||
return nameSlug.endsWith(`-${year}`) ? nameSlug : `${nameSlug}-${year}`;
|
||||
};
|
||||
|
||||
const generateUniqueSlug = async (conn: any, name: string, eventDate: string): Promise<string> => {
|
||||
const base = slugBase(name, eventDate);
|
||||
let candidate = base;
|
||||
let suffix = 2;
|
||||
// Small table, small admin audience - a loop is simpler and safer than
|
||||
// a clever single query, and collisions will be rare in practice.
|
||||
while (true) {
|
||||
const rows = await conn.query('SELECT 1 FROM events WHERE slug = ?', [candidate]);
|
||||
if (rows.length === 0) return candidate;
|
||||
candidate = `${base}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
};
|
||||
|
||||
export const createEvent = async (input: CreateEventInput, createdByEmail: string): Promise<number> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const slug = await generateUniqueSlug(conn, input.name, input.eventDate);
|
||||
const deadline = input.feedbackDeadline
|
||||
? new Date(input.feedbackDeadline)
|
||||
: computeDefaultDeadline(input.eventDate);
|
||||
|
||||
const query = `
|
||||
INSERT INTO events (slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, created_by_email)
|
||||
VALUES (?,?,?,?,?,?,?,?) RETURNING event_id`;
|
||||
const res = await conn.query(query, [
|
||||
slug, input.name, input.subtitle || null, input.eventDate, formatDatetime(deadline),
|
||||
input.introText || null, input.posterImageUrl || null, createdByEmail
|
||||
]);
|
||||
await conn.commit();
|
||||
return res[0].event_id;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export const getEventAdmin = async (eventId: number): Promise<EventAdminDetail | null> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
const eventRows = await conn.query(`
|
||||
SELECT e.*, COUNT(s.submission_id) as submission_count
|
||||
FROM events e
|
||||
LEFT JOIN submissions s ON s.event_id = e.event_id
|
||||
WHERE e.event_id = ?
|
||||
GROUP BY e.event_id`, [eventId]);
|
||||
if (eventRows.length === 0) return null;
|
||||
const row = eventRows[0];
|
||||
|
||||
const songRows = await conn.query('SELECT song_id, title, composer, position FROM songs WHERE event_id = ? ORDER BY position ASC', [eventId]);
|
||||
const songs: Song[] = songRows.map((r: any) => ({songId: r.song_id, title: r.title, composer: r.composer, position: r.position}));
|
||||
|
||||
const questionRows = await conn.query(
|
||||
'SELECT event_question_id, question_id, position, is_active FROM event_questions WHERE event_id = ? ORDER BY position ASC',
|
||||
[eventId]
|
||||
);
|
||||
const questions: EventAdminQuestionAssignment[] = questionRows.map((r: any) => ({
|
||||
eventQuestionId: r.event_question_id, questionId: r.question_id, position: r.position, isActive: !!r.is_active
|
||||
}));
|
||||
|
||||
return {
|
||||
...mapSummaryRow(row),
|
||||
introText: row.intro_text,
|
||||
songs,
|
||||
questions
|
||||
};
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export const updateEvent = async (eventId: number, input: UpdateEventInput): Promise<boolean> => {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (input.name !== undefined) { fields.push('name = ?'); values.push(input.name); }
|
||||
if (input.subtitle !== undefined) { fields.push('subtitle = ?'); values.push(input.subtitle); }
|
||||
if (input.eventDate !== undefined) { fields.push('event_date = ?'); values.push(input.eventDate); }
|
||||
if (input.feedbackDeadline !== undefined) { fields.push('feedback_deadline = ?'); values.push(input.feedbackDeadline); }
|
||||
if (input.isPublished !== undefined) { fields.push('is_published = ?'); values.push(input.isPublished ? 1 : 0); }
|
||||
if (input.introText !== undefined) { fields.push('intro_text = ?'); values.push(input.introText); }
|
||||
if (input.posterImageUrl !== undefined) { fields.push('poster_image_url = ?'); values.push(input.posterImageUrl || null); }
|
||||
|
||||
if (fields.length === 0) return true;
|
||||
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
values.push(eventId);
|
||||
const res = await conn.query(`UPDATE events SET ${fields.join(', ')} WHERE event_id = ?`, values);
|
||||
await conn.commit();
|
||||
return res.affectedRows > 0;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export type DeleteEventResult = 'DELETED' | 'NOT_FOUND' | 'HAS_SUBMISSIONS';
|
||||
|
||||
/**
|
||||
* Deletes an event and everything under it. Children are deleted in
|
||||
* explicit dependency order rather than left to the DB's ON DELETE CASCADE
|
||||
* chain: submission_answers and guest_book_entries are reachable from
|
||||
* `events` via two different cascade paths (direct event_id FK, and via
|
||||
* `submissions`/`songs`), and MariaDB can reject that as an ambiguous
|
||||
* multi-path cascade. See IMPLEMENTATION_PLAN.md Phase 1 notes.
|
||||
*/
|
||||
export const deleteEvent = async (eventId: number, force: boolean): Promise<DeleteEventResult> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const eventRows = await conn.query('SELECT event_id FROM events WHERE event_id = ?', [eventId]);
|
||||
if (eventRows.length === 0) {
|
||||
await conn.rollback();
|
||||
return 'NOT_FOUND';
|
||||
}
|
||||
|
||||
const countRows = await conn.query('SELECT COUNT(*) as cnt FROM submissions WHERE event_id = ?', [eventId]);
|
||||
const submissionCount = Number(countRows[0].cnt);
|
||||
if (submissionCount > 0 && !force) {
|
||||
await conn.rollback();
|
||||
return 'HAS_SUBMISSIONS';
|
||||
}
|
||||
|
||||
await conn.query('DELETE FROM guest_book_entries WHERE event_id = ?', [eventId]);
|
||||
await conn.query('DELETE FROM newsletter_signups WHERE event_id = ?', [eventId]);
|
||||
await conn.query('DELETE FROM submission_answers WHERE event_id = ?', [eventId]);
|
||||
await conn.query('DELETE FROM submissions WHERE event_id = ?', [eventId]);
|
||||
await conn.query('DELETE FROM event_questions WHERE event_id = ?', [eventId]);
|
||||
await conn.query('DELETE FROM songs WHERE event_id = ?', [eventId]);
|
||||
await conn.query('DELETE FROM events WHERE event_id = ?', [eventId]);
|
||||
|
||||
await conn.commit();
|
||||
return 'DELETED';
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export const reorderSongs = async (eventId: number, songIds: number[]): Promise<void> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
for (let i = 0; i < songIds.length; i++) {
|
||||
await conn.query('UPDATE songs SET position = ? WHERE song_id = ? AND event_id = ?', [i, songIds[i], eventId]);
|
||||
}
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export interface QuestionAssignmentItem {
|
||||
questionId: number;
|
||||
position: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-sets an event's assigned questions in one transaction: inserts new
|
||||
* assignments, updates existing ones' position/active state, and removes
|
||||
* ones no longer present in `items`.
|
||||
*/
|
||||
export const setEventQuestions = async (eventId: number, items: QuestionAssignmentItem[]): Promise<void> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const existingRows = await conn.query('SELECT question_id FROM event_questions WHERE event_id = ?', [eventId]);
|
||||
const existingIds = new Set<number>(existingRows.map((r: any) => r.question_id));
|
||||
const nextIds = new Set<number>(items.map((i) => i.questionId));
|
||||
|
||||
for (const existingId of existingIds) {
|
||||
if (!nextIds.has(existingId)) {
|
||||
await conn.query('DELETE FROM event_questions WHERE event_id = ? AND question_id = ?', [eventId, existingId]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (existingIds.has(item.questionId)) {
|
||||
await conn.query(
|
||||
'UPDATE event_questions SET position = ?, is_active = ? WHERE event_id = ? AND question_id = ?',
|
||||
[item.position, item.isActive ? 1 : 0, eventId, item.questionId]
|
||||
);
|
||||
} else {
|
||||
await conn.query(
|
||||
'INSERT INTO event_questions (event_id, question_id, position, is_active) VALUES (?,?,?,?)',
|
||||
[eventId, item.questionId, item.position, item.isActive ? 1 : 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Required External Modules and Interfaces
|
||||
*/
|
||||
import express, {Request, Response} from 'express';
|
||||
import * as QuestionsAdminService from './questions.admin.service';
|
||||
import {sendServerError} from '../feedback.errors';
|
||||
|
||||
/**
|
||||
* Router Definition
|
||||
*/
|
||||
export const questionsAdminRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/admin/questions:
|
||||
* get:
|
||||
* summary: List the question library
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: query
|
||||
* name: includeArchived
|
||||
* schema:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AdminQuestion'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* post:
|
||||
* summary: Create a question
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [label, questionType]
|
||||
* properties:
|
||||
* label:
|
||||
* type: string
|
||||
* helpText:
|
||||
* type: string
|
||||
* questionType:
|
||||
* $ref: '#/components/schemas/QuestionType'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created
|
||||
* 400:
|
||||
* description: Missing or invalid fields
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
questionsAdminRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const includeArchived = req.query.includeArchived === 'true';
|
||||
res.status(200).send(await QuestionsAdminService.listQuestions(includeArchived));
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
const VALID_TYPES = ['SONG_PICK', 'SONG_RATING', 'FREE_TEXT'];
|
||||
|
||||
questionsAdminRouter.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {label, helpText, questionType} = req.body || {};
|
||||
if (!label || !VALID_TYPES.includes(questionType)) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'label and a valid questionType are required'});
|
||||
return;
|
||||
}
|
||||
const questionId = await QuestionsAdminService.createQuestion(label, helpText || null, questionType);
|
||||
res.status(201).send({questionId});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/admin/questions/{questionId}:
|
||||
* put:
|
||||
* summary: Edit a question's label/help text
|
||||
* description: question_type is immutable after creation - the admin UI offers "archive and create new" instead.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: questionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [label]
|
||||
* properties:
|
||||
* label:
|
||||
* type: string
|
||||
* helpText:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Updated
|
||||
* 400:
|
||||
* description: Missing label
|
||||
* 404:
|
||||
* description: Unknown question
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* delete:
|
||||
* summary: Archive (or hard-delete) a question
|
||||
* description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: questionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Archived or deleted
|
||||
* 404:
|
||||
* description: Unknown question
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {label, helpText} = req.body || {};
|
||||
if (!label) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'label is required'});
|
||||
return;
|
||||
}
|
||||
const updated = await QuestionsAdminService.updateQuestion(Number(req.params.questionId), label, helpText || null);
|
||||
if (!updated) {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send({status: 'OK'});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
questionsAdminRouter.delete('/:questionId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await QuestionsAdminService.removeQuestion(Number(req.params.questionId));
|
||||
if (result === 'NOT_FOUND') {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send({status: result});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
||||
import {QuestionType} from '../feedback.interface';
|
||||
import {AdminQuestion} from './admin.interface';
|
||||
|
||||
const mapRow = (row: any): AdminQuestion => ({
|
||||
questionId: row.question_id,
|
||||
label: row.label,
|
||||
helpText: row.help_text,
|
||||
questionType: row.question_type,
|
||||
isArchived: !!row.is_archived
|
||||
});
|
||||
|
||||
export const listQuestions = async (includeArchived: boolean): Promise<AdminQuestion[]> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
const query = includeArchived
|
||||
? 'SELECT * FROM questions ORDER BY created_at DESC'
|
||||
: 'SELECT * FROM questions WHERE is_archived = 0 ORDER BY created_at DESC';
|
||||
const rows = await conn.query(query);
|
||||
return rows.map(mapRow);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export const createQuestion = async (label: string, helpText: string | null, questionType: QuestionType): Promise<number> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const res = await conn.query(
|
||||
'INSERT INTO questions (label, help_text, question_type) VALUES (?,?,?) RETURNING question_id',
|
||||
[label, helpText, questionType]
|
||||
);
|
||||
await conn.commit();
|
||||
return res[0].question_id;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits label/help text only. question_type is immutable after creation -
|
||||
* changing it would invalidate existing answers' question_type_snapshot
|
||||
* semantics. The admin UI offers "archive and create new" instead.
|
||||
*/
|
||||
export const updateQuestion = async (questionId: number, label: string, helpText: string | null): Promise<boolean> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const res = await conn.query('UPDATE questions SET label = ?, help_text = ? WHERE question_id = ?', [label, helpText, questionId]);
|
||||
await conn.commit();
|
||||
return res.affectedRows > 0;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export type RemoveQuestionResult = 'ARCHIVED' | 'DELETED' | 'NOT_FOUND';
|
||||
|
||||
/**
|
||||
* Archives (soft delete) a question. Hard-deletes it instead if it has
|
||||
* never been assigned to any event, so an admin's typo doesn't have to
|
||||
* live forever in the library.
|
||||
*/
|
||||
export const removeQuestion = async (questionId: number): Promise<RemoveQuestionResult> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const existsRows = await conn.query('SELECT 1 FROM questions WHERE question_id = ?', [questionId]);
|
||||
if (existsRows.length === 0) {
|
||||
await conn.rollback();
|
||||
return 'NOT_FOUND';
|
||||
}
|
||||
|
||||
const usageRows = await conn.query('SELECT 1 FROM event_questions WHERE question_id = ? LIMIT 1', [questionId]);
|
||||
if (usageRows.length === 0) {
|
||||
await conn.query('DELETE FROM questions WHERE question_id = ?', [questionId]);
|
||||
await conn.commit();
|
||||
return 'DELETED';
|
||||
}
|
||||
|
||||
await conn.query('UPDATE questions SET is_archived = 1 WHERE question_id = ?', [questionId]);
|
||||
await conn.commit();
|
||||
return 'ARCHIVED';
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import {QuestionType} from '../feedback.interface';
|
||||
|
||||
export interface SongPickResult {
|
||||
songId: number;
|
||||
title: string;
|
||||
votes: number;
|
||||
}
|
||||
|
||||
export interface SongPickReport {
|
||||
questionId: number | null;
|
||||
label: string;
|
||||
totalVotes: number;
|
||||
results: SongPickResult[];
|
||||
}
|
||||
|
||||
export interface SongRatingResult {
|
||||
songId: number;
|
||||
title: string;
|
||||
average: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SongRatingReport {
|
||||
questionId: number | null;
|
||||
label: string;
|
||||
results: SongRatingResult[];
|
||||
}
|
||||
|
||||
export interface FreeTextResponse {
|
||||
submissionId: number;
|
||||
submittedAt: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface FreeTextReport {
|
||||
questionId: number | null;
|
||||
label: string;
|
||||
responses: FreeTextResponse[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface EventReport {
|
||||
event: {
|
||||
eventId: number;
|
||||
name: string;
|
||||
eventDate: string;
|
||||
feedbackDeadline: string;
|
||||
};
|
||||
totalSubmissions: number;
|
||||
firstSubmissionAt: string | null;
|
||||
lastSubmissionAt: string | null;
|
||||
songPicks: SongPickReport[];
|
||||
songRatings: SongRatingReport[];
|
||||
freeText: FreeTextReport[];
|
||||
guestBookCount: number;
|
||||
newsletter: {
|
||||
total: number;
|
||||
sent: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Raw answer row as read from submission_answers, joined with submissions.submitted_at. */
|
||||
export interface AnswerRow {
|
||||
submissionId: number;
|
||||
submittedAt: string;
|
||||
questionId: number | null;
|
||||
questionLabel: string;
|
||||
questionType: QuestionType;
|
||||
songId: number | null;
|
||||
songTitle: string | null;
|
||||
rating: number | null;
|
||||
textAnswer: string | null;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
||||
import {
|
||||
AnswerRow, EventReport, FreeTextReport, SongPickReport, SongRatingReport
|
||||
} from './reports.admin.interface';
|
||||
|
||||
const FREE_TEXT_CAP = 500;
|
||||
|
||||
/**
|
||||
* Pure aggregation over one event's answer rows - no DB access, so it's
|
||||
* directly unit-testable against fixture data. group key is questionId
|
||||
* when present, falling back to the label snapshot for answers whose
|
||||
* question was hard-deleted (question_id IS NULL).
|
||||
*/
|
||||
export const aggregateReport = (
|
||||
eventMeta: {eventId: number; name: string; eventDate: string; feedbackDeadline: string},
|
||||
submissionStats: {totalSubmissions: number; firstSubmissionAt: string | null; lastSubmissionAt: string | null},
|
||||
answerRows: AnswerRow[],
|
||||
guestBookCount: number,
|
||||
newsletterCounts: {total: number; sent: number; pending: number; failed: number; skipped: number}
|
||||
): EventReport => {
|
||||
const groupKey = (row: AnswerRow) => `${row.questionId ?? 'null'}::${row.questionLabel}`;
|
||||
|
||||
const songPickGroups = new Map<string, AnswerRow[]>();
|
||||
const songRatingGroups = new Map<string, AnswerRow[]>();
|
||||
const freeTextGroups = new Map<string, AnswerRow[]>();
|
||||
|
||||
for (const row of answerRows) {
|
||||
const key = groupKey(row);
|
||||
const target = row.questionType === 'SONG_PICK' ? songPickGroups
|
||||
: row.questionType === 'SONG_RATING' ? songRatingGroups
|
||||
: freeTextGroups;
|
||||
if (!target.has(key)) target.set(key, []);
|
||||
target.get(key)!.push(row);
|
||||
}
|
||||
|
||||
const songPicks: SongPickReport[] = [...songPickGroups.values()].map((rows) => {
|
||||
const votesBySong = new Map<number, {title: string; votes: number}>();
|
||||
for (const row of rows) {
|
||||
if (row.songId === null || row.songTitle === null) continue;
|
||||
const entry = votesBySong.get(row.songId) || {title: row.songTitle, votes: 0};
|
||||
entry.votes += 1;
|
||||
votesBySong.set(row.songId, entry);
|
||||
}
|
||||
const results = [...votesBySong.entries()]
|
||||
.map(([songId, v]) => ({songId, title: v.title, votes: v.votes}))
|
||||
.sort((a, b) => b.votes - a.votes);
|
||||
return {
|
||||
questionId: rows[0].questionId,
|
||||
label: rows[0].questionLabel,
|
||||
totalVotes: results.reduce((sum, r) => sum + r.votes, 0),
|
||||
results
|
||||
};
|
||||
});
|
||||
|
||||
const songRatings: SongRatingReport[] = [...songRatingGroups.values()].map((rows) => {
|
||||
const sumsBySong = new Map<number, {title: string; sum: number; count: number}>();
|
||||
for (const row of rows) {
|
||||
if (row.songId === null || row.songTitle === null || row.rating === null) continue;
|
||||
const entry = sumsBySong.get(row.songId) || {title: row.songTitle, sum: 0, count: 0};
|
||||
entry.sum += row.rating;
|
||||
entry.count += 1;
|
||||
sumsBySong.set(row.songId, entry);
|
||||
}
|
||||
const results = [...sumsBySong.entries()]
|
||||
.map(([songId, v]) => ({songId, title: v.title, average: Math.round((v.sum / v.count) * 10) / 10, count: v.count}))
|
||||
.sort((a, b) => b.average - a.average);
|
||||
return {questionId: rows[0].questionId, label: rows[0].questionLabel, results};
|
||||
});
|
||||
|
||||
const freeText: FreeTextReport[] = [...freeTextGroups.values()].map((rows) => {
|
||||
const sorted = rows
|
||||
.filter((row) => row.textAnswer !== null)
|
||||
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime());
|
||||
const responses = sorted.slice(0, FREE_TEXT_CAP).map((row) => ({
|
||||
submissionId: row.submissionId,
|
||||
submittedAt: row.submittedAt,
|
||||
text: row.textAnswer!
|
||||
}));
|
||||
return {
|
||||
questionId: rows[0].questionId,
|
||||
label: rows[0].questionLabel,
|
||||
responses,
|
||||
hasMore: sorted.length > FREE_TEXT_CAP
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
event: eventMeta,
|
||||
totalSubmissions: submissionStats.totalSubmissions,
|
||||
firstSubmissionAt: submissionStats.firstSubmissionAt,
|
||||
lastSubmissionAt: submissionStats.lastSubmissionAt,
|
||||
songPicks,
|
||||
songRatings,
|
||||
freeText,
|
||||
guestBookCount,
|
||||
newsletter: newsletterCounts
|
||||
};
|
||||
};
|
||||
|
||||
export const getReport = async (eventId: number): Promise<EventReport | null> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
const eventRows = await conn.query('SELECT event_id, name, event_date, feedback_deadline FROM events WHERE event_id = ?', [eventId]);
|
||||
if (eventRows.length === 0) return null;
|
||||
const eventRow = eventRows[0];
|
||||
|
||||
const statsRows = await conn.query(
|
||||
'SELECT COUNT(*) as cnt, MIN(submitted_at) as first_at, MAX(submitted_at) as last_at FROM submissions WHERE event_id = ?',
|
||||
[eventId]
|
||||
);
|
||||
const stats = statsRows[0];
|
||||
|
||||
const answerRows = await conn.query(
|
||||
`SELECT sa.submission_id, s.submitted_at, sa.question_id, sa.question_label_snapshot,
|
||||
sa.question_type, sa.song_id, sa.song_title_snapshot, sa.rating, sa.text_answer
|
||||
FROM submission_answers sa
|
||||
INNER JOIN submissions s ON s.submission_id = sa.submission_id
|
||||
WHERE sa.event_id = ?`,
|
||||
[eventId]
|
||||
);
|
||||
const answers: AnswerRow[] = answerRows.map((r: any) => ({
|
||||
submissionId: r.submission_id,
|
||||
submittedAt: r.submitted_at,
|
||||
questionId: r.question_id,
|
||||
questionLabel: r.question_label_snapshot,
|
||||
questionType: r.question_type,
|
||||
songId: r.song_id,
|
||||
songTitle: r.song_title_snapshot,
|
||||
rating: r.rating,
|
||||
textAnswer: r.text_answer
|
||||
}));
|
||||
|
||||
const guestBookRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]);
|
||||
const guestBookCount = Number(guestBookRows[0].cnt);
|
||||
|
||||
const newsletterRows = await conn.query(
|
||||
`SELECT sync_status, COUNT(*) as cnt FROM newsletter_signups WHERE event_id = ? GROUP BY sync_status`,
|
||||
[eventId]
|
||||
);
|
||||
const newsletterCounts = {total: 0, sent: 0, pending: 0, failed: 0, skipped: 0};
|
||||
for (const row of newsletterRows) {
|
||||
const cnt = Number(row.cnt);
|
||||
newsletterCounts.total += cnt;
|
||||
if (row.sync_status === 'SENT') newsletterCounts.sent = cnt;
|
||||
else if (row.sync_status === 'PENDING') newsletterCounts.pending = cnt;
|
||||
else if (row.sync_status === 'FAILED') newsletterCounts.failed = cnt;
|
||||
else if (row.sync_status === 'SKIPPED') newsletterCounts.skipped = cnt;
|
||||
}
|
||||
|
||||
return aggregateReport(
|
||||
{eventId: eventRow.event_id, name: eventRow.name, eventDate: eventRow.event_date, feedbackDeadline: eventRow.feedback_deadline},
|
||||
{totalSubmissions: Number(stats.cnt), firstSubmissionAt: stats.first_at, lastSubmissionAt: stats.last_at},
|
||||
answers,
|
||||
guestBookCount,
|
||||
newsletterCounts
|
||||
);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export interface GuestBookEntry {
|
||||
entryId: number;
|
||||
submissionId: number;
|
||||
submittedAt: string;
|
||||
displayName: string | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
// 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();
|
||||
try {
|
||||
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(
|
||||
`SELECT entry_id, submission_id, created_at, display_name, message FROM guest_book_entries ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
||||
[...whereParams, pageSize, (page - 1) * pageSize]
|
||||
);
|
||||
return {
|
||||
total: Number(totalRows[0].cnt),
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
export interface NewsletterSignupRow {
|
||||
signupId: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
consentAt: string;
|
||||
syncStatus: string;
|
||||
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,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
search?: string
|
||||
): Promise<{entries: NewsletterSignupRow[]; total: number}> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
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(
|
||||
`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 ?`,
|
||||
[...whereParams, pageSize, (page - 1) * pageSize]
|
||||
);
|
||||
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,
|
||||
consentAt: r.consent_at, syncStatus: r.sync_status, lastError: r.last_error
|
||||
}))
|
||||
};
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Required External Modules and Interfaces
|
||||
*/
|
||||
import express, {Request, Response} from 'express';
|
||||
import * as SongsAdminService from './songs.admin.service';
|
||||
import {sendServerError} from '../feedback.errors';
|
||||
|
||||
/**
|
||||
* Router Definition
|
||||
*/
|
||||
export const songsAdminRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/admin/songs/{songId}:
|
||||
* put:
|
||||
* summary: Edit a song's title/composer
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: songId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [title]
|
||||
* properties:
|
||||
* title:
|
||||
* type: string
|
||||
* composer:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Updated
|
||||
* 400:
|
||||
* description: Missing title
|
||||
* 404:
|
||||
* description: Unknown song
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* delete:
|
||||
* summary: Remove a song
|
||||
* description: Past answers keep their song_title_snapshot even after the song is removed.
|
||||
* tags: [feedback-admin]
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||
* - in: path
|
||||
* name: songId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Removed
|
||||
* 404:
|
||||
* description: Unknown song
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
songsAdminRouter.put('/:songId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {title, composer} = req.body || {};
|
||||
if (!title) {
|
||||
res.status(400).send({status: 'BAD_REQUEST', message: 'title is required'});
|
||||
return;
|
||||
}
|
||||
const updated = await SongsAdminService.updateSong(Number(req.params.songId), title, composer || null);
|
||||
if (!updated) {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send({status: 'OK'});
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
songsAdminRouter.delete('/:songId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const deleted = await SongsAdminService.deleteSong(Number(req.params.songId));
|
||||
if (!deleted) {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
||||
|
||||
export const addSong = async (eventId: number, title: string, composer: string | null): Promise<number> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const posRows = await conn.query('SELECT COALESCE(MAX(position), -1) + 1 as next_position FROM songs WHERE event_id = ?', [eventId]);
|
||||
const position = posRows[0].next_position;
|
||||
const res = await conn.query(
|
||||
'INSERT INTO songs (event_id, title, composer, position) VALUES (?,?,?,?) RETURNING song_id',
|
||||
[eventId, title, composer, position]
|
||||
);
|
||||
await conn.commit();
|
||||
return res[0].song_id;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSong = async (songId: number, title: string, composer: string | null): Promise<boolean> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const res = await conn.query('UPDATE songs SET title = ?, composer = ? WHERE song_id = ?', [title, composer, songId]);
|
||||
await conn.commit();
|
||||
return res.affectedRows > 0;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a song. submission_answers rows referencing it keep their
|
||||
* song_title_snapshot (song_id is set to NULL via ON DELETE SET NULL) -
|
||||
* past answers still say what song was rated, even after the song is gone.
|
||||
*/
|
||||
export const deleteSong = async (songId: number): Promise<boolean> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const res = await conn.query('DELETE FROM songs WHERE song_id = ?', [songId]);
|
||||
await conn.commit();
|
||||
return res.affectedRows > 0;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user