Add Feedback domain module: public submission flow, admin CRUD, reporting (#7)
Jenkins Production Deployment
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>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import {NachklangFeedbackDB} from '../Feedback.db';
|
||||
import {EventConfig, EventSummary, Question, Song} from '../feedback.interface';
|
||||
|
||||
/**
|
||||
* Returns all events currently eligible to receive feedback:
|
||||
* published, on or after their concert day, and before the deadline.
|
||||
*/
|
||||
export const getEligibleEvents = async (): Promise<EventSummary[]> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
const query = `
|
||||
SELECT slug, name, subtitle, event_date, feedback_deadline, poster_image_url
|
||||
FROM events
|
||||
WHERE is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()
|
||||
ORDER BY event_date DESC`;
|
||||
const rows = await conn.query(query);
|
||||
return rows.map((row: any) => ({
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
subtitle: row.subtitle,
|
||||
eventDate: row.event_date,
|
||||
feedbackDeadline: row.feedback_deadline,
|
||||
posterImageUrl: row.poster_image_url
|
||||
}));
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
|
||||
export type EventLookupResult =
|
||||
| { status: 'OK'; eventId: number; event: EventConfig }
|
||||
| { status: 'NOT_FOUND' }
|
||||
| { status: 'CLOSED' };
|
||||
|
||||
/**
|
||||
* Resolves a slug to its full public config: meta, ordered setlist, ordered
|
||||
* active questions. Distinguishes "unknown slug" from "known but outside
|
||||
* its feedback window" so callers can respond 404 vs 410. Also used
|
||||
* internally by the submission flow, which additionally needs `eventId`.
|
||||
*/
|
||||
export const getEventConfigBySlug = async (slug: string): Promise<EventLookupResult> => {
|
||||
let conn = await NachklangFeedbackDB.getConnection();
|
||||
try {
|
||||
const eventQuery = `
|
||||
SELECT event_id, slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, is_published
|
||||
FROM events WHERE slug = ?`;
|
||||
const eventRows = await conn.query(eventQuery, [slug]);
|
||||
if (eventRows.length === 0) {
|
||||
return {status: 'NOT_FOUND'};
|
||||
}
|
||||
const eventRow = eventRows[0];
|
||||
|
||||
const eligibleQuery = `
|
||||
SELECT 1 FROM events
|
||||
WHERE event_id = ? AND is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()`;
|
||||
const eligibleRows = await conn.query(eligibleQuery, [eventRow.event_id]);
|
||||
if (eligibleRows.length === 0) {
|
||||
return {status: 'CLOSED'};
|
||||
}
|
||||
|
||||
const songsQuery = 'SELECT song_id, title, composer, position FROM songs WHERE event_id = ? ORDER BY position ASC';
|
||||
const songRows = await conn.query(songsQuery, [eventRow.event_id]);
|
||||
const songs: Song[] = songRows.map((row: any) => ({
|
||||
songId: row.song_id,
|
||||
title: row.title,
|
||||
composer: row.composer,
|
||||
position: row.position
|
||||
}));
|
||||
|
||||
const questionsQuery = `
|
||||
SELECT eq.event_question_id, eq.position, q.question_id, q.question_type, q.label, q.help_text
|
||||
FROM event_questions eq
|
||||
INNER JOIN questions q ON q.question_id = eq.question_id
|
||||
WHERE eq.event_id = ? AND eq.is_active = 1
|
||||
ORDER BY eq.position ASC`;
|
||||
const questionRows = await conn.query(questionsQuery, [eventRow.event_id]);
|
||||
const questions: Question[] = questionRows.map((row: any) => ({
|
||||
eventQuestionId: row.event_question_id,
|
||||
questionId: row.question_id,
|
||||
type: row.question_type,
|
||||
label: row.label,
|
||||
helpText: row.help_text,
|
||||
position: row.position
|
||||
}));
|
||||
|
||||
return {
|
||||
status: 'OK',
|
||||
eventId: eventRow.event_id,
|
||||
event: {
|
||||
slug: eventRow.slug,
|
||||
name: eventRow.name,
|
||||
subtitle: eventRow.subtitle,
|
||||
eventDate: eventRow.event_date,
|
||||
feedbackDeadline: eventRow.feedback_deadline,
|
||||
posterImageUrl: eventRow.poster_image_url,
|
||||
introText: eventRow.intro_text,
|
||||
songs,
|
||||
questions
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Required External Modules and Interfaces
|
||||
*/
|
||||
import express, {Request, Response} from 'express';
|
||||
import logger from '../../../middleware/logger';
|
||||
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service';
|
||||
import {submitFeedback} from './submissions.service';
|
||||
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit';
|
||||
import {sendServerError} from '../feedback.errors';
|
||||
|
||||
/**
|
||||
* Router Definition
|
||||
*/
|
||||
export const publicRouter = express.Router();
|
||||
|
||||
/**
|
||||
* True if the honeypot field was filled in — a real visitor never types
|
||||
* into it, since it's hidden with CSS only. Pulled out as a pure function
|
||||
* so the short-circuit behaviour is unit-testable without a live DB.
|
||||
*/
|
||||
export const isHoneypotTriggered = (body: any): boolean => {
|
||||
return typeof body?.website === 'string' && body.website.trim().length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/events:
|
||||
* get:
|
||||
* summary: List currently eligible events
|
||||
* description: Returns events that are published, on or after their concert day, and before their feedback deadline. An empty array is a valid, expected response.
|
||||
* tags:
|
||||
* - feedback
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/EventSummary'
|
||||
* 500:
|
||||
* description: Server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ProcessingError'
|
||||
*/
|
||||
publicRouter.get('/events', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const events = await getEligibleEvents();
|
||||
res.status(200).send(events);
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/events/{slug}:
|
||||
* get:
|
||||
* summary: Get the full public config for one event
|
||||
* description: Returns event meta, ordered setlist, and ordered active questions. 404 if the slug is unknown, 410 if the event exists but is outside its feedback window.
|
||||
* tags:
|
||||
* - feedback
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: slug
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/EventConfig'
|
||||
* 404:
|
||||
* description: Unknown slug
|
||||
* 410:
|
||||
* description: Event exists but feedback is closed
|
||||
* 500:
|
||||
* description: Server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ProcessingError'
|
||||
*/
|
||||
publicRouter.get('/events/:slug', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await getEventConfigBySlug(req.params.slug);
|
||||
if (result.status === 'NOT_FOUND') {
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
}
|
||||
if (result.status === 'CLOSED') {
|
||||
res.status(410).send({status: 'FEEDBACK_CLOSED'});
|
||||
return;
|
||||
}
|
||||
res.status(200).send(result.event);
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /feedback/events/{slug}/submissions:
|
||||
* post:
|
||||
* summary: Submit feedback for an event
|
||||
* description: Every field is optional; the only validation error the public form can produce is EMPTY_SUBMISSION (nothing was filled in). Rate-limited per IP hash and honeypot-checked.
|
||||
* tags:
|
||||
* - feedback
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: slug
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SubmissionRequest'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Submitted
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/SubmissionResponse'
|
||||
* 400:
|
||||
* description: Nothing was filled in
|
||||
* 404:
|
||||
* description: Unknown slug
|
||||
* 410:
|
||||
* description: Event exists but feedback is closed
|
||||
* 429:
|
||||
* description: Rate limited
|
||||
* 500:
|
||||
* description: Server error
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ProcessingError'
|
||||
*/
|
||||
publicRouter.post('/events/:slug/submissions', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
|
||||
// Honeypot: a real visitor never fills this in. Fake success, persist
|
||||
// nothing, stay silent about it having failed.
|
||||
if (isHoneypotTriggered(body)) {
|
||||
logger.info('Feedback honeypot triggered', {slug: req.params.slug});
|
||||
res.status(201).send({submissionId: -1, newsletterDropped: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const ipHash = hashIp(req.ip || '');
|
||||
|
||||
if (await isRateLimited(ipHash)) {
|
||||
res.status(429).send({status: 'RATE_LIMITED'});
|
||||
return;
|
||||
}
|
||||
|
||||
// Count every request that reaches this point against the limit,
|
||||
// regardless of outcome - an attacker sending EMPTY/NOT_FOUND/CLOSED
|
||||
// requests still costs DB round-trips per attempt and must not get an
|
||||
// unlimited number of free ones.
|
||||
recordSubmission(ipHash);
|
||||
|
||||
const result = await submitFeedback(req.params.slug, body, ipHash);
|
||||
|
||||
switch (result.status) {
|
||||
case 'NOT_FOUND':
|
||||
res.status(404).send({status: 'NOT_FOUND'});
|
||||
return;
|
||||
case 'CLOSED':
|
||||
res.status(410).send({status: 'FEEDBACK_CLOSED'});
|
||||
return;
|
||||
case 'EMPTY':
|
||||
res.status(400).send({status: 'EMPTY_SUBMISSION'});
|
||||
return;
|
||||
case 'OK':
|
||||
res.status(201).send({submissionId: result.submissionId, newsletterDropped: result.newsletterDropped});
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
sendServerError(res, e);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* SubmissionRequest:
|
||||
* type: object
|
||||
* properties:
|
||||
* answers:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* eventQuestionId:
|
||||
* type: integer
|
||||
* example: 12
|
||||
* songId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* description: SONG_PICK only
|
||||
* ratings:
|
||||
* type: array
|
||||
* description: SONG_RATING only
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* songId:
|
||||
* type: integer
|
||||
* rating:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 5
|
||||
* text:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: FREE_TEXT only
|
||||
* guestBook:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* properties:
|
||||
* displayName:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* message:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* newsletter:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* properties:
|
||||
* firstName:
|
||||
* type: string
|
||||
* lastName:
|
||||
* type: string
|
||||
* email:
|
||||
* type: string
|
||||
* website:
|
||||
* type: string
|
||||
* description: Honeypot field. Must stay empty; a real visitor never fills it in.
|
||||
* SubmissionResponse:
|
||||
* type: object
|
||||
* properties:
|
||||
* submissionId:
|
||||
* type: integer
|
||||
* example: 91
|
||||
* newsletterDropped:
|
||||
* type: boolean
|
||||
* description: True if the newsletter opt-in was present but failed validation (e.g. a malformed email) - the rest of the submission still saved.
|
||||
*/
|
||||
|
||||
export interface RatingInput {
|
||||
songId: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface AnswerInput {
|
||||
eventQuestionId: number;
|
||||
songId?: number;
|
||||
ratings?: RatingInput[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface GuestBookInput {
|
||||
displayName?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface NewsletterInput {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface SubmissionRequestBody {
|
||||
answers?: AnswerInput[];
|
||||
guestBook?: GuestBookInput;
|
||||
newsletter?: NewsletterInput;
|
||||
website?: string;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user