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,142 @@
|
||||
-- Nachklang e.V. Feedback module — initial schema for FEEDBACK_DB
|
||||
-- Apply manually against the FEEDBACK_DB database (separate from CALENDAR_DB).
|
||||
-- See nachklang-feedback/IMPLEMENTATION_PLAN.md §2 for the full rationale
|
||||
-- behind every design decision below (snapshot columns, denormalisation,
|
||||
-- absence-over-sentinels, hashed IPs only).
|
||||
--
|
||||
-- Apply with e.g.:
|
||||
-- mysql -h <DB_HOST> -u <DB_USER> -p <FEEDBACK_DB> < 001_init.sql
|
||||
|
||||
USE `nachklang-feedback`;
|
||||
|
||||
-- 1. events -------------------------------------------------------------
|
||||
CREATE TABLE events (
|
||||
event_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(80) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
subtitle VARCHAR(255) NULL,
|
||||
event_date DATE NOT NULL,
|
||||
feedback_deadline DATETIME NOT NULL,
|
||||
is_published TINYINT(1) NOT NULL DEFAULT 0,
|
||||
intro_text TEXT NULL,
|
||||
created_by_email VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_events_slug (slug),
|
||||
KEY idx_events_eligibility (is_published, event_date, feedback_deadline)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 2. songs (per-event setlist) ------------------------------------------
|
||||
CREATE TABLE songs (
|
||||
song_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
event_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
composer VARCHAR(255) NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_songs_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
|
||||
KEY idx_songs_event_position (event_id, position)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 3. questions (global reusable library) ---------------------------------
|
||||
CREATE TABLE questions (
|
||||
question_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
label VARCHAR(500) NOT NULL,
|
||||
help_text VARCHAR(500) NULL,
|
||||
question_type ENUM('SONG_PICK','SONG_RATING','FREE_TEXT') NOT NULL,
|
||||
is_archived TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_questions_archived_type (is_archived, question_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 4. event_questions (join + ordering) -----------------------------------
|
||||
CREATE TABLE event_questions (
|
||||
event_question_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
event_id INT NOT NULL,
|
||||
question_id INT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_eq_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_eq_question FOREIGN KEY (question_id) REFERENCES questions(question_id) ON DELETE RESTRICT,
|
||||
UNIQUE KEY uq_eq_event_question (event_id, question_id),
|
||||
KEY idx_eq_event_position (event_id, position, is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 5. submissions ----------------------------------------------------------
|
||||
CREATE TABLE submissions (
|
||||
submission_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
event_id INT NOT NULL,
|
||||
submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_hash CHAR(64) NULL,
|
||||
has_guestbook TINYINT(1) NOT NULL DEFAULT 0,
|
||||
has_newsletter TINYINT(1) NOT NULL DEFAULT 0,
|
||||
CONSTRAINT fk_sub_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
|
||||
KEY idx_sub_event_time (event_id, submitted_at),
|
||||
KEY idx_sub_iphash_time (ip_hash, submitted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 6. submission_answers ----------------------------------------------------
|
||||
-- Deliberate denormalisation: question label/type and song title are
|
||||
-- snapshotted at submission time so later edits to the question library
|
||||
-- never retroactively change what a past submission means.
|
||||
CREATE TABLE submission_answers (
|
||||
answer_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
submission_id INT NOT NULL,
|
||||
event_id INT NOT NULL,
|
||||
question_id INT NULL,
|
||||
event_question_id INT NULL,
|
||||
question_label_snapshot VARCHAR(500) NOT NULL,
|
||||
question_type ENUM('SONG_PICK','SONG_RATING','FREE_TEXT') NOT NULL,
|
||||
position_snapshot INT NOT NULL DEFAULT 0,
|
||||
song_id INT NULL,
|
||||
song_title_snapshot VARCHAR(255) NULL,
|
||||
rating TINYINT NULL,
|
||||
text_answer TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_ans_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_ans_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_ans_question FOREIGN KEY (question_id) REFERENCES questions(question_id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_ans_song FOREIGN KEY (song_id) REFERENCES songs(song_id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_ans_rating CHECK (rating IS NULL OR (rating BETWEEN 1 AND 5)),
|
||||
KEY idx_ans_submission (submission_id),
|
||||
KEY idx_ans_report (event_id, question_id, song_id),
|
||||
KEY idx_ans_type (event_id, question_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 7. guest_book_entries -----------------------------------------------------
|
||||
-- Private, admin-only. No public wall in v1.
|
||||
CREATE TABLE guest_book_entries (
|
||||
entry_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
submission_id INT NOT NULL,
|
||||
event_id INT NOT NULL,
|
||||
display_name VARCHAR(255) NULL,
|
||||
message TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_gb_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_gb_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
|
||||
KEY idx_gb_event_time (event_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 8. newsletter_signups -----------------------------------------------------
|
||||
CREATE TABLE newsletter_signups (
|
||||
signup_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
submission_id INT NOT NULL,
|
||||
event_id INT NOT NULL,
|
||||
first_name VARCHAR(120) NOT NULL,
|
||||
last_name VARCHAR(120) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
consent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
consent_text_version VARCHAR(40) NOT NULL,
|
||||
sync_status ENUM('PENDING','SENT','FAILED','SKIPPED') NOT NULL DEFAULT 'PENDING',
|
||||
sync_attempts INT NOT NULL DEFAULT 0,
|
||||
synced_at DATETIME NULL,
|
||||
external_id VARCHAR(120) NULL,
|
||||
last_error TEXT NULL,
|
||||
CONSTRAINT fk_nl_submission FOREIGN KEY (submission_id) REFERENCES submissions(submission_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_nl_event FOREIGN KEY (event_id) REFERENCES events(event_id) ON DELETE CASCADE,
|
||||
KEY idx_nl_sync_status (sync_status),
|
||||
KEY idx_nl_email (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
Reference in New Issue
Block a user