Files
API/sql/feedback/001_init.sql
T
Paddy 17ca6399e0 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>
2026-08-05 23:32:22 +02:00

143 lines
7.7 KiB
SQL

-- 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;