diff --git a/.env.example b/.env.example index c208dac..77d02c6 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,8 @@ FEEDBACK_RATE_LIMIT_MAX=5 FEEDBACK_RATE_LIMIT_WINDOW_MIN=10 SALESFORCE_ENABLED=false SALESFORCE_API_URL= -SALESFORCE_API_TOKEN= +SALESFORCE_CLIENT_ID= +SALESFORCE_CLIENT_SECRET= MEMBER_CREDENTIAL=123 CHOIR_CREDENTIAL=123 diff --git a/CLAUDE.md b/CLAUDE.md index 2d5ba39..d201fb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ npx jest test/some.test.ts ## 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:** 1. `app.ts` mounts `Calendar.router.ts` at `/calendar` @@ -59,7 +59,8 @@ FEEDBACK_RATE_LIMIT_MAX= FEEDBACK_RATE_LIMIT_WINDOW_MIN= SALESFORCE_ENABLED= SALESFORCE_API_URL= -SALESFORCE_API_TOKEN= +SALESFORCE_CLIENT_ID= +SALESFORCE_CLIENT_SECRET= EMAIL_HOST= EMAIL_USERNAME= EMAIL_PASSWORD= diff --git a/src/models/feedback/integrations/salesforce.service.ts b/src/models/feedback/integrations/salesforce.service.ts new file mode 100644 index 0000000..5534fae --- /dev/null +++ b/src/models/feedback/integrations/salesforce.service.ts @@ -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 => { + 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); + } +}; diff --git a/src/models/feedback/public/submissions.service.ts b/src/models/feedback/public/submissions.service.ts index 8e91145..9d74c02 100644 --- a/src/models/feedback/public/submissions.service.ts +++ b/src/models/feedback/public/submissions.service.ts @@ -2,6 +2,8 @@ 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. @@ -186,20 +188,31 @@ export const submitFeedback = async (slug: string, body: SubmissionRequestBody, await conn.query(gbQuery, [submissionId, eventId, guestBook.displayName, guestBook.message]); } + let newsletterSignupId: number | null = null; if (newsletter) { - // SALESFORCE_ENABLED is false until Phase 4's contract is known; the - // signup is always persisted locally first regardless of sync outcome. + // 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 (?,?,?,?,?,?,?)`; - await conn.query(nlQuery, [ + 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}; } catch (err) { await conn.rollback();