import axios from 'axios'; import {NachklangFeedbackDB} from '../Feedback.db'; import logger from '../../../middleware/logger'; // Newsletter opt-ins sync to Salesforce, which already runs a full // double-opt-in subscription flow (Person Account for existing constituents, // Lead for everyone else - see the Salesforce repo's // feature/newsletter-signup-integration branch for the full design notes). // This is the one file that knows that contract exists; submissions.service.ts // only ever calls syncNewsletterSignup(signupId) after its own transaction // commits, fire-and-forget, so a Salesforce outage can never delay or fail a // visitor's feedback submission. interface SalesforceSuccessResponse { status: 'PENDING_CONFIRMATION' | 'ALREADY_SUBSCRIBED'; salesforceObject: 'Lead' | 'Account'; salesforceRecordId: string; created: boolean; } interface NewsletterSignupRow { signup_id: number; first_name: string; last_name: string; email: string; event_name: string; } // Salesforce's client-credentials token response does not reliably include // expires_in, so the cache lifetime is a conservative guess rather than a // value read from the response - a 401 on the next call just triggers a // fresh fetch (see the retry-once logic in postSignup). const TOKEN_CACHE_MS = 15 * 60 * 1000; let cachedToken: {accessToken: string; fetchedAt: number} | null = null; const getAccessToken = async (forceRefresh: boolean): Promise => { if (!forceRefresh && cachedToken && Date.now() - cachedToken.fetchedAt < TOKEN_CACHE_MS) { return cachedToken.accessToken; } const instanceUrl = process.env.SALESFORCE_API_URL; const clientId = process.env.SALESFORCE_CLIENT_ID; const clientSecret = process.env.SALESFORCE_CLIENT_SECRET; if (!instanceUrl || !clientId || !clientSecret) { throw new Error('SALESFORCE_ENABLED is true but SALESFORCE_API_URL/SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET are not fully configured.'); } const res = await axios.post( `${instanceUrl}/services/oauth2/token`, new URLSearchParams({grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret}).toString(), {headers: {'Content-Type': 'application/x-www-form-urlencoded'}, timeout: 10000} ); cachedToken = {accessToken: res.data.access_token, fetchedAt: Date.now()}; return cachedToken.accessToken; }; const postSignup = async (payload: {firstName: string; lastName: string; email: string; eventName: string}): Promise => { const instanceUrl = process.env.SALESFORCE_API_URL; const url = `${instanceUrl}/services/apexrest/newsletter/signup`; try { const token = await getAccessToken(false); const res = await axios.post(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000}); return res.data; } catch (err: any) { // The cached token may have expired server-side even though our // conservative local TTL hasn't - retry once with a forced refresh // before treating this as a real failure. if (err?.response?.status === 401) { const token = await getAccessToken(true); const res = await axios.post(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000}); return res.data; } throw err; } }; const markSynced = async (signupId: number, externalId: string): Promise => { let conn = await NachklangFeedbackDB.getConnection(); try { await conn.query( `UPDATE newsletter_signups SET sync_status = 'SENT', synced_at = NOW(), external_id = ?, sync_attempts = sync_attempts + 1, last_error = NULL WHERE signup_id = ?`, [externalId, signupId] ); } finally { await conn.end(); } }; const markFailed = async (signupId: number, errorMessage: string): Promise => { let conn = await NachklangFeedbackDB.getConnection(); try { await conn.query( `UPDATE newsletter_signups SET sync_status = 'FAILED', last_error = ?, sync_attempts = sync_attempts + 1 WHERE signup_id = ?`, [errorMessage.slice(0, 2000), signupId] ); } finally { await conn.end(); } }; /** * Reads one newsletter_signups row and syncs it to Salesforce. Always * called after the owning submission's transaction has committed, never * awaited by the request handler. When SALESFORCE_ENABLED is false, this * only logs the payload it would have sent - the row's sync_status is * already 'SKIPPED' from the insert in submissions.service.ts, so there's * nothing to update. */ export const syncNewsletterSignup = async (signupId: number): Promise => { let conn = await NachklangFeedbackDB.getConnection(); let row: NewsletterSignupRow | undefined; try { const rows = await conn.query( `SELECT ns.signup_id, ns.first_name, ns.last_name, ns.email, e.name AS event_name FROM newsletter_signups ns JOIN events e ON e.event_id = ns.event_id WHERE ns.signup_id = ?`, [signupId] ); row = rows[0]; } finally { await conn.end(); } if (!row) { logger.error('syncNewsletterSignup: signup not found', {signupId}); return; } const payload = {firstName: row.first_name, lastName: row.last_name, email: row.email, eventName: row.event_name}; if (process.env.SALESFORCE_ENABLED !== 'true') { logger.info('syncNewsletterSignup: SALESFORCE_ENABLED is false, would have sent', {signupId, payload}); return; } try { const result = await postSignup(payload); await markSynced(signupId, result.salesforceRecordId); } catch (err: any) { const message = err?.response?.data?.message || err?.message || 'Unknown error'; logger.error('syncNewsletterSignup failed', {signupId, message}); await markFailed(signupId, message); } };