Wire up the Salesforce newsletter sync integration
Implements integrations/salesforce.service.ts per the plan's §5.6 seam
(syncNewsletterSignup(signupId)), against the real contract now that the
Salesforce side exists (see the nk-salesforce repo's
feature/newsletter-signup-integration branch): OAuth2 client-credentials
auth, POST to /services/apexrest/newsletter/signup with
{firstName, lastName, email, eventName}, response gives back which object
(Lead or Person Account) and its id. Token is cached in memory with a
conservative TTL and refreshed on a 401 rather than trusting expires_in,
which Salesforce's client-credentials token response doesn't reliably
return.
submissions.service.ts now captures the newsletter_signups insert's id
and fires syncNewsletterSignup after commit, fire-and-forget - the one
piece that was previously entirely missing, so flipping
SALESFORCE_ENABLED=true would have left every signup stuck at PENDING
forever with nothing to process it (found during an earlier review pass).
Replaced the placeholder SALESFORCE_API_TOKEN env var with
SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET in .env.example and
CLAUDE.md, matching the real auth mechanism instead of the static-token
guess from before the contract was known. Also fixed CLAUDE.md's stale
"still scaffolding-only" note about the Feedback domain.
Not yet covered by tests - the Salesforce-side contract was validated
end-to-end against a real sandbox, but this file has no unit tests yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -16,7 +16,8 @@ FEEDBACK_RATE_LIMIT_MAX=5
|
|||||||
FEEDBACK_RATE_LIMIT_WINDOW_MIN=10
|
FEEDBACK_RATE_LIMIT_WINDOW_MIN=10
|
||||||
SALESFORCE_ENABLED=false
|
SALESFORCE_ENABLED=false
|
||||||
SALESFORCE_API_URL=
|
SALESFORCE_API_URL=
|
||||||
SALESFORCE_API_TOKEN=
|
SALESFORCE_CLIENT_ID=
|
||||||
|
SALESFORCE_CLIENT_SECRET=
|
||||||
|
|
||||||
MEMBER_CREDENTIAL=123
|
MEMBER_CREDENTIAL=123
|
||||||
CHOIR_CREDENTIAL=123
|
CHOIR_CREDENTIAL=123
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ npx jest test/some.test.ts
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Express.js REST API in TypeScript with a service-oriented layering. Domains: `Calendar` (events, users) and `Feedback` (concert feedback forms, mounted at `/feedback`, backed by its own `FEEDBACK_DB` — see `src/models/feedback/`, still scaffolding-only as of this writing).
|
Express.js REST API in TypeScript with a service-oriented layering. Domains: `Calendar` (events, users) and `Feedback` (concert feedback forms, mounted at `/feedback`, backed by its own `FEEDBACK_DB` — see `src/models/feedback/`: public submission flow, admin CRUD, reporting, and a Salesforce newsletter-sync integration).
|
||||||
|
|
||||||
**Request path:**
|
**Request path:**
|
||||||
1. `app.ts` mounts `Calendar.router.ts` at `/calendar`
|
1. `app.ts` mounts `Calendar.router.ts` at `/calendar`
|
||||||
@@ -59,7 +59,8 @@ FEEDBACK_RATE_LIMIT_MAX=
|
|||||||
FEEDBACK_RATE_LIMIT_WINDOW_MIN=
|
FEEDBACK_RATE_LIMIT_WINDOW_MIN=
|
||||||
SALESFORCE_ENABLED=
|
SALESFORCE_ENABLED=
|
||||||
SALESFORCE_API_URL=
|
SALESFORCE_API_URL=
|
||||||
SALESFORCE_API_TOKEN=
|
SALESFORCE_CLIENT_ID=
|
||||||
|
SALESFORCE_CLIENT_SECRET=
|
||||||
EMAIL_HOST=
|
EMAIL_HOST=
|
||||||
EMAIL_USERNAME=
|
EMAIL_USERNAME=
|
||||||
EMAIL_PASSWORD=
|
EMAIL_PASSWORD=
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,6 +2,8 @@ import {NachklangFeedbackDB} from '../Feedback.db';
|
|||||||
import {QuestionType} from '../feedback.interface';
|
import {QuestionType} from '../feedback.interface';
|
||||||
import {getEventConfigBySlug} from './events.public.service';
|
import {getEventConfigBySlug} from './events.public.service';
|
||||||
import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface';
|
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
|
// 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.
|
// changes; recorded per-signup so a past consent's exact wording is provable.
|
||||||
@@ -186,20 +188,31 @@ export const submitFeedback = async (slug: string, body: SubmissionRequestBody,
|
|||||||
await conn.query(gbQuery, [submissionId, eventId, guestBook.displayName, guestBook.message]);
|
await conn.query(gbQuery, [submissionId, eventId, guestBook.displayName, guestBook.message]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let newsletterSignupId: number | null = null;
|
||||||
if (newsletter) {
|
if (newsletter) {
|
||||||
// SALESFORCE_ENABLED is false until Phase 4's contract is known; the
|
// The signup is always persisted locally first, regardless of sync
|
||||||
// signup is always persisted locally first regardless of sync outcome.
|
// 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 salesforceEnabled = process.env.SALESFORCE_ENABLED === 'true';
|
||||||
const nlQuery = `INSERT INTO newsletter_signups
|
const nlQuery = `INSERT INTO newsletter_signups
|
||||||
(submission_id, event_id, first_name, last_name, email, consent_text_version, sync_status)
|
(submission_id, event_id, first_name, last_name, email, consent_text_version, sync_status)
|
||||||
VALUES (?,?,?,?,?,?,?)`;
|
VALUES (?,?,?,?,?,?,?) RETURNING signup_id`;
|
||||||
await conn.query(nlQuery, [
|
const nlRes = await conn.query(nlQuery, [
|
||||||
submissionId, eventId, newsletter.firstName, newsletter.lastName, newsletter.email,
|
submissionId, eventId, newsletter.firstName, newsletter.lastName, newsletter.email,
|
||||||
CONSENT_TEXT_VERSION, salesforceEnabled ? 'PENDING' : 'SKIPPED'
|
CONSENT_TEXT_VERSION, salesforceEnabled ? 'PENDING' : 'SKIPPED'
|
||||||
]);
|
]);
|
||||||
|
newsletterSignupId = nlRes[0].signup_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.commit();
|
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};
|
return {status: 'OK', submissionId};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await conn.rollback();
|
await conn.rollback();
|
||||||
|
|||||||
Reference in New Issue
Block a user