Add Feedback domain module: public submission flow, admin CRUD, reporting
New /feedback API domain backed by its own FEEDBACK_DB, mirroring the Calendar domain's router -> service -> DB pool layering: - Public endpoints (no auth): eligible-events listing, event config, submission with honeypot + rate limiting (in-memory + DB backstop). - Admin endpoints (session-header auth, reusing Calendar's users/sessions via a swappable feedback.auth.ts boundary): events/songs/questions CRUD, bulk reorder/assignment, aggregated reporting, CSV export. - Schema in sql/feedback/001_init.sql (8 tables), applied and verified against the real FEEDBACK_DB. - 64 Jest tests covering validation, auth, rate limiting, CSV escaping, and report aggregation (pure functions, no DB needed). Includes fixes from a security review: path traversal defense doesn't apply here (that's the frontend proxy, separate repo), but the rate-limiter cluster does - recordSubmission now counts every processed request (not just successful ones), the in-memory Map evicts empty entries instead of growing unbounded, FEEDBACK_IP_SALT is required at boot instead of silently degrading to unsalted hashing, and submission answer/rating arrays are capped and de-duplicated to bound insert amplification. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user