Files
API/src/models/feedback/integrations/salesforce.service.ts
T
Paddy b05f6b9da0
Jenkins Production Deployment
Add Feedback domain module: public submission flow, admin CRUD, reporting (#7)
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>
2026-08-23 09:39:02 +00:00

146 lines
5.5 KiB
TypeScript

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<string> => {
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<SalesforceSuccessResponse> => {
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<SalesforceSuccessResponse>(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<SalesforceSuccessResponse>(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
}
throw err;
}
};
const markSynced = async (signupId: number, externalId: string): Promise<void> => {
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<void> => {
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<void> => {
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);
}
};