import {NachklangFeedbackDB} from '../Feedback.db'; import {QuestionType} from '../feedback.interface'; import {getEventConfigBySlug} from './events.public.service'; import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface'; import {syncNewsletterSignup} from '../integrations/salesforce.service'; import logger from '../../../middleware/logger'; // Bump when the privacy/consent copy shown next to the newsletter opt-in // changes; recorded per-signup so a past consent's exact wording is provable. const CONSENT_TEXT_VERSION = '2026-08-02'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // A real setlist tops out around a few dozen songs and a handful of // questions, so a legitimate submission never comes close to this. Caps // total generated rows regardless of how large the client's answers/ratings // arrays are, bounding the number of INSERTs one request can trigger. export const MAX_ANSWER_ROWS = 200; export interface ValidatedAnswerRow { eventQuestionId: number; questionId: number; label: string; type: QuestionType; position: number; songId: number | null; songTitle: string | null; rating: number | null; text: string | null; } export interface ValidatedGuestBook { displayName: string | null; message: string | null; } export interface ValidatedNewsletter { firstName: string; lastName: string; email: string; } /** * Validates raw answers against the event's *actual* active questions and * songs. Unknown eventQuestionId/songId are ignored rather than erroring — * a stale tab must not lose someone's comment. Empty answers are dropped * entirely; "no row" is the canonical representation of "skipped". */ export const validateAnswers = ( answers: AnswerInput[], questionsById: Map, songTitleById: Map ): ValidatedAnswerRow[] => { const rows: ValidatedAnswerRow[] = []; const pushRow = (row: ValidatedAnswerRow): boolean => { if (rows.length >= MAX_ANSWER_ROWS) return false; rows.push(row); return true; }; outer: for (const answer of answers) { const question = questionsById.get(answer.eventQuestionId); if (!question) continue; if (question.type === 'SONG_PICK') { if (answer.songId != null && songTitleById.has(answer.songId)) { if (!pushRow({ eventQuestionId: question.eventQuestionId, questionId: question.questionId, label: question.label, type: 'SONG_PICK', position: question.position, songId: answer.songId, songTitle: songTitleById.get(answer.songId)!, rating: null, text: null })) break outer; } } else if (question.type === 'SONG_RATING') { // De-duplicate by songId (last value wins) before generating rows, // so a client can't force one row per repeated entry for the same // song by simply repeating it in the ratings array. const ratingBySong = new Map(); for (const r of answer.ratings || []) { if (!songTitleById.has(r.songId)) continue; ratingBySong.set(r.songId, Math.min(5, Math.max(1, Math.round(r.rating)))); } for (const [songId, clamped] of ratingBySong) { if (!pushRow({ eventQuestionId: question.eventQuestionId, questionId: question.questionId, label: question.label, type: 'SONG_RATING', position: question.position, songId, songTitle: songTitleById.get(songId)!, rating: clamped, text: null })) break outer; } } else if (question.type === 'FREE_TEXT') { const trimmed = (answer.text || '').trim(); if (trimmed.length > 0) { if (!pushRow({ eventQuestionId: question.eventQuestionId, questionId: question.questionId, label: question.label, type: 'FREE_TEXT', position: question.position, songId: null, songTitle: null, rating: null, text: trimmed.slice(0, 5000) })) break outer; } } } return rows; }; export const validateGuestBook = (input?: GuestBookInput): ValidatedGuestBook | null => { if (!input) return null; const displayName = (input.displayName || '').trim().slice(0, 255) || null; const message = (input.message || '').trim().slice(0, 2000) || null; if (!displayName && !message) return null; return {displayName, message}; }; export const validateNewsletter = (input?: NewsletterInput): ValidatedNewsletter | null => { if (!input) return null; const firstName = (input.firstName || '').trim().slice(0, 120); const lastName = (input.lastName || '').trim().slice(0, 120); const email = (input.email || '').trim().slice(0, 255); if (!firstName || !lastName || !EMAIL_RE.test(email)) return null; return {firstName, lastName, email}; }; export type SubmitResult = | { status: 'OK'; submissionId: number; newsletterDropped: boolean } | { status: 'NOT_FOUND' } | { status: 'CLOSED' } | { status: 'EMPTY' }; /** * Validates and persists one feedback submission. Re-checks event * eligibility (the window may have closed between page load and submit), * validates every answer against the event's live questions/songs, then * inserts everything in a single transaction. */ export const submitFeedback = async (slug: string, body: SubmissionRequestBody, ipHash: string | null): Promise => { const lookup = await getEventConfigBySlug(slug); if (lookup.status === 'NOT_FOUND') return {status: 'NOT_FOUND'}; if (lookup.status === 'CLOSED') return {status: 'CLOSED'}; const {eventId, event} = lookup; const questionsById = new Map(event.questions.map(q => [q.eventQuestionId, q])); const songTitleById = new Map(event.songs.map(s => [s.songId, s.title])); const answerRows = validateAnswers(body.answers || [], questionsById, songTitleById); const guestBook = validateGuestBook(body.guestBook); const newsletter = validateNewsletter(body.newsletter); // body.newsletter is only sent at all when the visitor had the opt-in // checkbox on (see FeedbackForm.tsx), so a present-but-invalid block // (e.g. a mistyped email) is distinguishable from "didn't opt in" - the // rest of the submission still saves, but the client can tell the // visitor their newsletter signup specifically didn't go through. const newsletterDropped = !!body.newsletter && !newsletter; if (answerRows.length === 0 && !guestBook && !newsletter) { return {status: 'EMPTY'}; } let conn = await NachklangFeedbackDB.getConnection(); try { await conn.beginTransaction(); const subQuery = 'INSERT INTO submissions (event_id, ip_hash, has_guestbook, has_newsletter) VALUES (?,?,?,?) RETURNING submission_id'; const subRes = await conn.query(subQuery, [eventId, ipHash, guestBook ? 1 : 0, newsletter ? 1 : 0]); const submissionId = subRes[0].submission_id; for (const row of answerRows) { const ansQuery = `INSERT INTO submission_answers (submission_id, event_id, question_id, event_question_id, question_label_snapshot, question_type, position_snapshot, song_id, song_title_snapshot, rating, text_answer) VALUES (?,?,?,?,?,?,?,?,?,?,?)`; await conn.query(ansQuery, [ submissionId, eventId, row.questionId, row.eventQuestionId, row.label, row.type, row.position, row.songId, row.songTitle, row.rating, row.text ]); } if (guestBook) { const gbQuery = 'INSERT INTO guest_book_entries (submission_id, event_id, display_name, message) VALUES (?,?,?,?)'; await conn.query(gbQuery, [submissionId, eventId, guestBook.displayName, guestBook.message]); } let newsletterSignupId: number | null = null; if (newsletter) { // The signup is always persisted locally first, regardless of sync // outcome - syncNewsletterSignup (fired after commit, below) is what // actually talks to Salesforce and moves PENDING to SENT/FAILED. const salesforceEnabled = process.env.SALESFORCE_ENABLED === 'true'; const nlQuery = `INSERT INTO newsletter_signups (submission_id, event_id, first_name, last_name, email, consent_text_version, sync_status) VALUES (?,?,?,?,?,?,?) RETURNING signup_id`; const nlRes = await conn.query(nlQuery, [ submissionId, eventId, newsletter.firstName, newsletter.lastName, newsletter.email, CONSENT_TEXT_VERSION, salesforceEnabled ? 'PENDING' : 'SKIPPED' ]); newsletterSignupId = nlRes[0].signup_id; } await conn.commit(); if (newsletterSignupId !== null) { const signupId = newsletterSignupId; void syncNewsletterSignup(signupId).catch((err) => { logger.error('syncNewsletterSignup threw outside its own error handling', {signupId, error: String(err)}); }); } return {status: 'OK', submissionId, newsletterDropped}; } catch (err) { await conn.rollback(); throw err; } finally { await conn.end(); } };