b05f6b9da0
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>
230 lines
8.6 KiB
TypeScript
230 lines
8.6 KiB
TypeScript
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<number, {eventQuestionId: number; questionId: number; type: QuestionType; label: string; position: number}>,
|
|
songTitleById: Map<number, string>
|
|
): 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<number, number>();
|
|
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<SubmitResult> => {
|
|
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();
|
|
}
|
|
};
|