From 123561f035c67060b285045136cce6512d5deebb Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Fri, 9 Oct 2026 21:32:45 -0400 Subject: [PATCH 01/10] Add CRM screener integrations and client demo examples --- builder-frontend/public/integrations/host.js | 130 +++++++ .../src/components/screener/FormRenderer.tsx | 6 + .../src/components/screener/Screener.test.tsx | 146 +++++++ .../src/components/screener/Screener.tsx | 48 ++- .../src/integrations/screenerBridge.test.ts | 303 +++++++++++++++ .../src/integrations/screenerBridge.ts | 139 +++++++ docs/astro.config.mjs | 4 + docs/src/content/docs/dev/crm-integrations.md | 235 ++++++++++++ e2e/playwright.config.ts | 15 + e2e/tests/crmIntegration.spec.ts | 236 ++++++++++++ examples/crm/browser/README.md | 82 ++++ examples/crm/browser/clientMapping.mjs | 37 ++ examples/crm/browser/demo.html | 358 ++++++++++++++++++ examples/crm/browser/demo.js | 196 ++++++++++ examples/crm/browser/serve.mjs | 48 +++ .../crm/browser/tests/clientMapping.test.mjs | 64 ++++ examples/crm/salesforce/.prettierrc.json | 8 + .../BDT_Client_Record.flexipage-meta.xml | 64 ++++ .../BDT_Screening.permissionset-meta.xml | 120 ++++++ .../BDT_Screening__c.field-meta.xml | 8 + .../Community_Client_Services.app-meta.xml | 22 ++ .../Community_Clients.listView-meta.xml | 12 + .../Contact-BDT Client Layout.layout-meta.xml | 118 ++++++ examples/crm/salesforce/README.md | 119 ++++++ .../bdtClientHeader/bdtClientHeader.css | 29 ++ .../bdtClientHeader/bdtClientHeader.html | 14 + .../bdtClientHeader/bdtClientHeader.js | 24 ++ .../bdtClientHeader.js-meta.xml | 6 + .../bdtClientIntake/bdtClientIntake.css | 31 ++ .../bdtClientIntake/bdtClientIntake.html | 87 +++++ .../bdtClientIntake/bdtClientIntake.js | 128 +++++++ .../bdtClientIntake.js-meta.xml | 9 + .../bdtCrmHost/bdtCrmHost.js-meta.xml | 5 + .../salesforce/bdtScreening/bdtScreening.css | 5 + .../salesforce/bdtScreening/bdtScreening.html | 29 ++ .../salesforce/bdtScreening/bdtScreening.js | 161 ++++++++ .../bdtScreening/bdtScreening.js-meta.xml | 12 + .../salesforce/bdtScreening/clientPrefill.js | 45 +++ .../bdtScreeningResults.css | 126 ++++++ .../bdtScreeningResults.html | 64 ++++ .../bdtScreeningResults.js | 48 +++ .../bdtScreeningResults.js-meta.xml | 12 + .../bdtScreeningResults/formatResults.js | 83 ++++ .../BDT_Client_Notes__c.field-meta.xml | 1 + .../BDT_Client_Status__c.field-meta.xml | 3 + .../BDT_Enrolled_Programs__c.field-meta.xml | 3 + .../BDT_Enrollment_Review__c.field-meta.xml | 3 + .../BDT_Has_Spouse__c.field-meta.xml | 3 + .../BDT_Household_Income__c.field-meta.xml | 3 + .../BDT_Household_Size__c.field-meta.xml | 3 + .../BDT_Intake_Date__c.field-meta.xml | 3 + .../BDT_Late_Spouse_65__c.field-meta.xml | 3 + .../BDT_Owner_Occupant__c.field-meta.xml | 3 + .../BDT_Philly_Resident__c.field-meta.xml | 3 + .../BDT_Preferred_Contact__c.field-meta.xml | 3 + .../BDT_Spouse_Birthdate__c.field-meta.xml | 3 + .../BDT_Tax_Abatement__c.field-meta.xml | 3 + .../BDT_Tax_Delinquent__c.field-meta.xml | 3 + .../BDT_Wants_Cash__c.field-meta.xml | 3 + .../salesforce/tests/clientPrefill.test.mjs | 76 ++++ 60 files changed, 3547 insertions(+), 11 deletions(-) create mode 100644 builder-frontend/public/integrations/host.js create mode 100644 builder-frontend/src/components/screener/Screener.test.tsx create mode 100644 builder-frontend/src/integrations/screenerBridge.test.ts create mode 100644 builder-frontend/src/integrations/screenerBridge.ts create mode 100644 docs/src/content/docs/dev/crm-integrations.md create mode 100644 e2e/tests/crmIntegration.spec.ts create mode 100644 examples/crm/browser/README.md create mode 100644 examples/crm/browser/clientMapping.mjs create mode 100644 examples/crm/browser/demo.html create mode 100644 examples/crm/browser/demo.js create mode 100644 examples/crm/browser/serve.mjs create mode 100644 examples/crm/browser/tests/clientMapping.test.mjs create mode 100644 examples/crm/salesforce/.prettierrc.json create mode 100644 examples/crm/salesforce/BDT_Client_Record.flexipage-meta.xml create mode 100644 examples/crm/salesforce/BDT_Screening.permissionset-meta.xml create mode 100644 examples/crm/salesforce/BDT_Screening__c.field-meta.xml create mode 100644 examples/crm/salesforce/Community_Client_Services.app-meta.xml create mode 100644 examples/crm/salesforce/Community_Clients.listView-meta.xml create mode 100644 examples/crm/salesforce/Contact-BDT Client Layout.layout-meta.xml create mode 100644 examples/crm/salesforce/README.md create mode 100644 examples/crm/salesforce/bdtClientHeader/bdtClientHeader.css create mode 100644 examples/crm/salesforce/bdtClientHeader/bdtClientHeader.html create mode 100644 examples/crm/salesforce/bdtClientHeader/bdtClientHeader.js create mode 100644 examples/crm/salesforce/bdtClientHeader/bdtClientHeader.js-meta.xml create mode 100644 examples/crm/salesforce/bdtClientIntake/bdtClientIntake.css create mode 100644 examples/crm/salesforce/bdtClientIntake/bdtClientIntake.html create mode 100644 examples/crm/salesforce/bdtClientIntake/bdtClientIntake.js create mode 100644 examples/crm/salesforce/bdtClientIntake/bdtClientIntake.js-meta.xml create mode 100644 examples/crm/salesforce/bdtCrmHost/bdtCrmHost.js-meta.xml create mode 100644 examples/crm/salesforce/bdtScreening/bdtScreening.css create mode 100644 examples/crm/salesforce/bdtScreening/bdtScreening.html create mode 100644 examples/crm/salesforce/bdtScreening/bdtScreening.js create mode 100644 examples/crm/salesforce/bdtScreening/bdtScreening.js-meta.xml create mode 100644 examples/crm/salesforce/bdtScreening/clientPrefill.js create mode 100644 examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.css create mode 100644 examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.html create mode 100644 examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.js create mode 100644 examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.js-meta.xml create mode 100644 examples/crm/salesforce/bdtScreeningResults/formatResults.js create mode 100644 examples/crm/salesforce/client-fields/BDT_Client_Notes__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Client_Status__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Enrolled_Programs__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Enrollment_Review__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Has_Spouse__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Household_Income__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Household_Size__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Intake_Date__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Late_Spouse_65__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Owner_Occupant__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Philly_Resident__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Preferred_Contact__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Spouse_Birthdate__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Tax_Abatement__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Tax_Delinquent__c.field-meta.xml create mode 100644 examples/crm/salesforce/client-fields/BDT_Wants_Cash__c.field-meta.xml create mode 100644 examples/crm/salesforce/tests/clientPrefill.test.mjs diff --git a/builder-frontend/public/integrations/host.js b/builder-frontend/public/integrations/host.js new file mode 100644 index 00000000..f1ffd73c --- /dev/null +++ b/builder-frontend/public/integrations/host.js @@ -0,0 +1,130 @@ +function randomId() { + return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +/** + * BDT CRM host adapter. Install the listener before navigating the iframe/popup. + * Copy this module into your CRM's source tree if remote modules are restricted. + * @param {{ + * screenerUrl: string, + * getTargetWindow: () => Window | null | undefined, + * initialData: Record, + * requestId?: string, + * serializeMessages?: boolean, + * onResult: (message: any) => void, + * onError?: (message: any) => void, + * onInitialized?: () => void, + * hostWindow?: Window + * }} options + */ +export function createCrmIntegration({ + screenerUrl, + getTargetWindow, + initialData, + requestId = randomId(), + serializeMessages = false, + onResult, + onError = () => {}, + onInitialized = () => {}, + hostWindow = window, +}) { + const url = new URL(screenerUrl); + const match = url.pathname.match(/^\/screener\/([^/]+)\/?$/); + if (!match || !["http:", "https:"].includes(url.protocol)) { + throw new Error("Expected an absolute published BDT screener URL"); + } + if (typeof requestId !== "string" || !requestId || requestId.length > 200) { + throw new Error("Expected a nonempty requestId of at most 200 characters"); + } + if ( + !initialData || + typeof initialData !== "object" || + Array.isArray(initialData) + ) { + throw new Error("Expected initialData to be a JSON object"); + } + const screenerId = decodeURIComponent(match[1]); + url.searchParams.set("integrationOrigin", hostWindow.location.origin); + // A new launch must reload an existing iframe even when its screener is unchanged. + url.searchParams.set("integrationLaunch", randomId()); + let sessionId; + let initialized = false; + const receive = (event) => { + const target = getTargetWindow(); + if (!target || event.origin !== url.origin || event.source !== target) + return; + let message = event.data; + if (typeof message === "string") { + try { + message = JSON.parse(message); + } catch { + return; + } + } + if ( + !message || + message.channel !== "bdt.crm" || + message.version !== 1 || + message.screenerId !== screenerId || + typeof message.sessionId !== "string" || + !message.sessionId + ) + return; + if (message.type === "ready") { + sessionId = message.sessionId; + initialized = false; + const initialization = { + channel: "bdt.crm", + version: 1, + type: "initialize", + screenerId, + sessionId, + requestId, + inputData: initialData, + }; + try { + target.postMessage( + serializeMessages ? JSON.stringify(initialization) : initialization, + url.origin, + ); + } catch (error) { + console.warn("BDT initialization could not be sent", error); + onError({ code: "INITIALIZATION_FAILED" }); + } + return; + } + if (message.sessionId !== sessionId || message.requestId !== requestId) + return; + if (message.type === "initialized") { + initialized = true; + onInitialized(); + } else if ( + initialized && + message.type === "result" && + message.results && + typeof message.results === "object" && + !Array.isArray(message.results) && + message.inputData && + typeof message.inputData === "object" && + !Array.isArray(message.inputData) && + typeof message.evaluatedAt === "string" + ) { + onResult(message); + } else if ( + initialized && + message.type === "error" && + message.code === "EVALUATION_FAILED" + ) { + onError(message); + } + }; + hostWindow.addEventListener("message", receive); + return { + url: url.href, + dispose() { + hostWindow.removeEventListener("message", receive); + }, + }; +} diff --git a/builder-frontend/src/components/screener/FormRenderer.tsx b/builder-frontend/src/components/screener/FormRenderer.tsx index bbf370fb..b4a1fef2 100644 --- a/builder-frontend/src/components/screener/FormRenderer.tsx +++ b/builder-frontend/src/components/screener/FormRenderer.tsx @@ -18,12 +18,14 @@ function FormRenderer({ hiddenQuestionPaths, submitForm, onDataChange, + evaluateInitialData = false, }: { schema: { [key: string]: any }; formData: Accessor; hiddenQuestionPaths: Accessor; submitForm: (data: any) => void; onDataChange?: () => void; + evaluateInitialData?: boolean; }) { let container: Element | null = null; let form: Form | undefined; @@ -61,6 +63,10 @@ function FormRenderer({ form .importSchema(hideQuestions(schema, hiddenQuestionPaths()), formData()) .then(() => { + currentData = cloneDeep(form?._getState().data || formData()); + if (evaluateInitialData) { + submitForm(normalizeArrayFieldData(schema, currentData)); + } form?.on("changed", (event: State) => { const dataChanged = !isEqual(currentData, event.data); currentData = cloneDeep(event.data); diff --git a/builder-frontend/src/components/screener/Screener.test.tsx b/builder-frontend/src/components/screener/Screener.test.tsx new file mode 100644 index 00000000..adbe5614 --- /dev/null +++ b/builder-frontend/src/components/screener/Screener.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom +import { onMount } from "solid-js"; +import { render } from "solid-js/web"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Screener from "./Screener"; +import { evaluatePublishedScreener } from "@/api/publishedScreener"; +import type { ScreenerResult } from "@/types"; + +const integration = vi.hoisted(() => ({ + enabled: true, + initialize: undefined as + | undefined + | ((data: Record) => void), + start: vi.fn(), + dispose: vi.fn(), + result: vi.fn(), + error: vi.fn(), +})); + +vi.mock("@solidjs/router", () => ({ + useParams: () => ({ publishedScreenerId: "published-id" }), +})); +vi.mock("@/api/publishedScreener", () => ({ + fetchPublishedScreener: vi.fn(async () => ({ + screenerName: "Test", + formSchema: {}, + })), + evaluatePublishedScreener: vi.fn(), +})); +vi.mock("@/integrations/screenerBridge", () => ({ + createScreeningBridge: ( + _: string, + initialize: typeof integration.initialize, + ) => { + integration.initialize = initialize; + return integration.enabled ? integration : undefined; + }, +})); +vi.mock("./FormRenderer", () => ({ + default: (props: any) => { + onMount(() => { + if (props.evaluateInitialData) props.submitForm(props.formData()); + }); + return ( + + ); + }, +})); + +const result = (value: "TRUE" | "FALSE"): ScreenerResult => ({ + benefit: { name: "Benefit", result: value, check_results: {} }, +}); +const initialData = { custom: { householdIncome: 30000 } }; +function deferred() { + let resolve!: (value: ScreenerResult) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +describe("published screener integration lifecycle", () => { + let container: HTMLDivElement; + let dispose: () => void; + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(evaluatePublishedScreener).mockReset(); + integration.enabled = true; + container = document.createElement("div"); + document.body.append(container); + }); + afterEach(() => { + dispose?.(); + container.remove(); + }); + + it("waits for prefill, then ignores an old response when a newer evaluation finishes", async () => { + const old = deferred(); + vi.mocked(evaluatePublishedScreener) + .mockReturnValueOnce(old.promise) + .mockResolvedValueOnce(result("FALSE")); + dispose = render(() => , container); + await vi.waitFor(() => expect(integration.start).toHaveBeenCalledOnce()); + expect(container.textContent).toContain("Waiting for information"); + expect(evaluatePublishedScreener).not.toHaveBeenCalled(); + integration.initialize!(initialData); + await vi.waitFor(() => + expect(evaluatePublishedScreener).toHaveBeenCalledWith( + "published-id", + initialData, + ), + ); + container.querySelector("button")!.click(); + await vi.waitFor(() => expect(integration.result).toHaveBeenCalledOnce()); + expect(integration.result).toHaveBeenLastCalledWith( + { custom: { householdIncome: 50000 } }, + result("FALSE"), + ); + old.resolve(result("TRUE")); + await old.promise; + expect(integration.result).toHaveBeenCalledOnce(); + expect(container.textContent).toContain("Ineligible"); + }); + + it("ignores old failures and pending responses after unmount", async () => { + const old = deferred(); + const current = deferred(); + vi.mocked(evaluatePublishedScreener) + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(current.promise); + dispose = render(() => , container); + integration.initialize!(initialData); + await vi.waitFor(() => + expect(evaluatePublishedScreener).toHaveBeenCalledOnce(), + ); + container.querySelector("button")!.click(); + old.reject(new Error("Old request failed")); + await old.promise.catch(() => {}); + expect(integration.error).not.toHaveBeenCalled(); + dispose(); + expect(integration.dispose).toHaveBeenCalledOnce(); + current.resolve(result("TRUE")); + await current.promise; + expect(integration.result).not.toHaveBeenCalled(); + }); + + it("keeps the standalone form available without waiting or initial evaluation", async () => { + integration.enabled = false; + vi.mocked(evaluatePublishedScreener).mockResolvedValue(result("TRUE")); + dispose = render(() => , container); + await vi.waitFor(() => + expect(container.querySelector("button")).not.toBeNull(), + ); + expect(container.textContent).not.toContain("Waiting for information"); + expect(evaluatePublishedScreener).not.toHaveBeenCalled(); + container.querySelector("button")!.click(); + await vi.waitFor(() => expect(container.textContent).toContain("Eligible")); + expect(integration.result).not.toHaveBeenCalled(); + }); +}); diff --git a/builder-frontend/src/components/screener/Screener.tsx b/builder-frontend/src/components/screener/Screener.tsx index 8466df9f..17dfc1f4 100644 --- a/builder-frontend/src/components/screener/Screener.tsx +++ b/builder-frontend/src/components/screener/Screener.tsx @@ -1,4 +1,11 @@ -import { createSignal, createResource, createMemo, Show } from "solid-js"; +import { + createSignal, + createResource, + createMemo, + Show, + onMount, + onCleanup, +} from "solid-js"; import { useParams } from "@solidjs/router"; import FormRenderer from "./FormRenderer"; @@ -18,6 +25,7 @@ import { getUnneededQuestionPaths, haveSameQuestionPaths, } from "@/utils/questionVotes"; +import { createScreeningBridge } from "@/integrations/screenerBridge"; export default function Screener() { const params = useParams(); @@ -27,11 +35,21 @@ export default function Screener() { ); const [screenerResult, setScreenerResult] = createSignal(); const [formData, setFormData] = createSignal({}); + const bridge = createScreeningBridge(params.publishedScreenerId, (data) => { + setFormData(data); + setIntegrationReady(true); + }); + const [integrationReady, setIntegrationReady] = createSignal(!bridge); + let evaluationSequence = 0; + onMount(() => bridge?.start()); + onCleanup(() => { + evaluationSequence++; + bridge?.dispose(); + }); // True from an edit until results for the latest answers arrive, so a failed evaluation // keeps the previous results visible without announcing that screening is complete. const [resultsStale, setResultsStale] = createSignal(false); const [evaluationFailed, setEvaluationFailed] = createSignal(false); - let evaluationVersion = 0; const [showAllQuestions, setShowAllQuestions] = createSignal(false); const unneededQuestionPaths = createMemo( () => getUnneededQuestionPaths(screenerResult()), @@ -45,7 +63,7 @@ export default function Screener() { ); const submitForm = async (data: any) => { - const version = ++evaluationVersion; + const sequence = ++evaluationSequence; setResultsStale(true); try { setFormData(data); @@ -53,13 +71,15 @@ export default function Screener() { params.publishedScreenerId, data, ); - if (version === evaluationVersion) { - setScreenerResult(evaluationResult); - setResultsStale(false); - setEvaluationFailed(false); - } + if (sequence !== evaluationSequence) return; + setScreenerResult(evaluationResult); + setResultsStale(false); + setEvaluationFailed(false); + bridge?.result(data, evaluationResult); } catch (err) { - if (version === evaluationVersion) setEvaluationFailed(true); + if (sequence !== evaluationSequence) return; + setEvaluationFailed(true); + bridge?.error(); console.log(err); } }; @@ -67,7 +87,12 @@ export default function Screener() { return (
{screener.loading && } - {screener() && ( + +

+ Waiting for information from the connected application… +

+
+ {screener() && integrationReady() && (
{ - evaluationVersion++; + evaluationSequence++; setResultsStale(true); }} /> diff --git a/builder-frontend/src/integrations/screenerBridge.test.ts b/builder-frontend/src/integrations/screenerBridge.test.ts new file mode 100644 index 00000000..4295cd5d --- /dev/null +++ b/builder-frontend/src/integrations/screenerBridge.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it, vi } from "vitest"; +import { createScreeningBridge } from "./screenerBridge"; +import { createCrmIntegration } from "../../public/integrations/host.js"; + +const results = { + benefit: { name: "Benefit", result: "TRUE" as const, check_results: {} }, +}; + +function setup(popup = false) { + const hostPost = vi.fn(); + const childPost = vi.fn(); + const host = Object.assign(new EventTarget(), { + location: { origin: "https://crm.example" }, + postMessage: hostPost, + }); + const child = Object.assign(new EventTarget(), { + location: { + href: "https://bdt.example/screener/published-id?integrationOrigin=https%3A%2F%2Fcrm.example", + }, + crypto: { getRandomValues: (bytes: Uint8Array) => bytes.fill(1) }, + parent: popup ? undefined : host, + opener: popup ? host : null, + postMessage: childPost, + }); + if (popup) child.parent = child as any; + const initialize = vi.fn(); + const bridge = createScreeningBridge( + "published-id", + initialize, + child as any, + )!; + const dispatch = ( + target: EventTarget, + source: unknown, + origin: string, + data: unknown, + ) => { + const event = new Event("message"); + Object.assign(event, { source, origin, data }); + target.dispatchEvent(event); + }; + const init = { + channel: "bdt.crm", + version: 1, + type: "initialize", + screenerId: "published-id", + sessionId: "01".repeat(16), + requestId: "request-1", + inputData: { people: { client: { age: 65 } } }, + }; + return { + bridge, + initialize, + child, + host, + init, + hostPost, + childPost, + send: ( + data = init, + origin = host.location.origin, + source: unknown = host, + ) => dispatch(child, source, origin, data), + receive: ( + data: unknown, + origin = "https://bdt.example", + source: unknown = child, + ) => dispatch(host, source, origin, data), + }; +} + +describe("published screener CRM bridge", () => { + it.each([false, true])( + "prefills once and correlates results for popup=%s", + (popup) => { + const s = setup(popup); + s.bridge.start(); + expect(s.hostPost).toHaveBeenCalledWith( + expect.objectContaining({ type: "ready", sessionId: "01".repeat(16) }), + "https://crm.example", + ); + s.bridge.result({}, results); + expect(s.hostPost).toHaveBeenCalledTimes(1); + s.send(); + expect(s.initialize).toHaveBeenCalledWith(s.init.inputData); + expect(s.initialize.mock.calls[0][0]).not.toBe(s.init.inputData); + s.send({ ...s.init, inputData: { age: 20 } } as any); + expect(s.initialize).toHaveBeenCalledTimes(1); + s.bridge.result(s.init.inputData, results); + expect(s.hostPost).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: "result", + requestId: "request-1", + screenerId: "published-id", + results, + inputData: s.init.inputData, + evaluatedAt: expect.any(String), + }), + "https://crm.example", + ); + s.bridge.error(); + expect(s.hostPost).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "error", code: "EVALUATION_FAILED" }), + "https://crm.example", + ); + s.bridge.dispose(); + s.bridge.result({}, results); + expect(s.hostPost).toHaveBeenCalledTimes(4); + }, + ); + + it("rejects wrong origins, sources, versions, sessions, screeners and unsafe payloads", () => { + const s = setup(); + s.bridge.start(); + s.send(s.init, "https://untrusted.example"); + s.send(s.init, "https://crm.example", {}); + for (const change of [ + { channel: "other" }, + { version: 2 }, + { type: "result" }, + { sessionId: "old" }, + { screenerId: "other" }, + { requestId: "" }, + { requestId: "x".repeat(201) }, + { inputData: [] }, + { inputData: null }, + { inputData: { age: Infinity } }, + { inputData: JSON.parse('{"custom":{"__proto__":{"polluted":true}}}') }, + ]) + s.send({ ...s.init, ...change } as any); + expect(s.initialize).not.toHaveBeenCalled(); + s.send(); + expect(s.initialize).toHaveBeenCalledTimes(1); + }); + + it("removes listeners on disposal", () => { + const s = setup(); + s.bridge.start(); + s.bridge.dispose(); + s.send(); + expect(s.initialize).not.toHaveBeenCalled(); + }); + + it.each([ + "", + "*", + "null", + "https://crm.example/path", + "https://crm.example/", + "javascript:alert(1)", + ])("does not enable integration with origin %s", (origin) => { + const s = setup(); + s.child.location.href = `https://bdt.example/screener/id?integrationOrigin=${encodeURIComponent(origin)}`; + expect( + createScreeningBridge("id", vi.fn(), s.child as any), + ).toBeUndefined(); + }); + + it("does not enable integration without a parent or opener", () => { + const s = setup(true); + s.child.opener = null as any; + expect( + createScreeningBridge("id", vi.fn(), s.child as any), + ).toBeUndefined(); + }); +}); + +describe("CRM host adapter", () => { + it("reports a blocked initialization and allows a later retry", () => { + const s = setup(true); + const onError = vi.fn(); + const onInitialized = vi.fn(); + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const host = createCrmIntegration({ + screenerUrl: "https://bdt.example/screener/published-id", + getTargetWindow: () => s.child as any, + initialData: s.init.inputData, + requestId: "request-1", + onResult: vi.fn(), + onError, + onInitialized, + hostWindow: s.host as any, + }); + try { + s.childPost.mockImplementationOnce(() => { + throw new Error("Blocked by host"); + }); + s.bridge.start(); + const ready = s.hostPost.mock.calls[0][0]; + s.receive(ready); + expect(onError).toHaveBeenCalledWith({ code: "INITIALIZATION_FAILED" }); + expect(onInitialized).not.toHaveBeenCalled(); + s.receive(ready); + s.send(s.childPost.mock.calls[1][0]); + s.receive(s.hostPost.mock.calls[1][0]); + expect(onInitialized).toHaveBeenCalledOnce(); + } finally { + warning.mockRestore(); + host.dispose(); + s.bridge.dispose(); + } + }); + it.each([false, true])( + "uses JSON envelopes for sandboxed hosts for popup=%s", + (popup) => { + const s = setup(popup); + const onResult = vi.fn(); + const host = createCrmIntegration({ + screenerUrl: "https://bdt.example/screener/published-id", + getTargetWindow: () => s.child as any, + initialData: { + people: { client: { enrollments: null } }, + custom: { householdIncome: 0 }, + }, + requestId: "request-1", + serializeMessages: true, + onResult, + hostWindow: s.host as any, + }); + s.bridge.start(); + s.receive(s.hostPost.mock.calls[0][0]); + const initialization = s.childPost.mock.calls[0][0]; + expect(typeof initialization).toBe("string"); + s.send(initialization, "https://untrusted.example"); + s.send(initialization, "https://crm.example", {}); + s.send("invalid JSON" as any); + s.send( + JSON.stringify({ + ...s.init, + inputData: JSON.parse('{"__proto__":{}}'), + }) as any, + ); + expect(s.initialize).not.toHaveBeenCalled(); + s.send(initialization); + expect(s.initialize).toHaveBeenCalledWith( + JSON.parse(initialization).inputData, + ); + expect(typeof s.hostPost.mock.calls[1][0]).toBe("string"); + s.receive(s.hostPost.mock.calls[1][0]); + s.bridge.result({}, results); + const serialized = s.hostPost.mock.calls[2][0]; + s.receive("invalid JSON"); + s.receive( + JSON.stringify({ ...JSON.parse(serialized), requestId: "wrong" }), + ); + expect(onResult).not.toHaveBeenCalled(); + s.receive(serialized); + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ results }), + ); + host.dispose(); + s.bridge.dispose(); + }, + ); + it.each([false, true])( + "completes the handshake and delivers live results for popup=%s", + (popup) => { + const s = setup(popup); + const onResult = vi.fn(); + const onError = vi.fn(); + const onInitialized = vi.fn(); + const host = createCrmIntegration({ + screenerUrl: "https://bdt.example/screener/published-id", + getTargetWindow: () => s.child as any, + initialData: s.init.inputData, + requestId: "request-1", + onResult, + onError, + onInitialized, + hostWindow: s.host as any, + }); + expect(new URL(host.url).searchParams.get("integrationOrigin")).toBe( + "https://crm.example", + ); + s.bridge.start(); + const ready = s.hostPost.mock.calls[0][0]; + s.receive(ready, "https://untrusted.example"); + s.receive(ready, "https://bdt.example", {}); + s.receive({ ...ready, version: 2 }); + expect(s.childPost).not.toHaveBeenCalled(); + s.receive(ready); + expect(s.childPost).toHaveBeenCalledWith(s.init, "https://bdt.example"); + s.send(s.childPost.mock.calls[0][0]); + s.receive(s.hostPost.mock.calls[1][0]); + expect(onInitialized).toHaveBeenCalledOnce(); + s.bridge.result(s.init.inputData, results); + const result = s.hostPost.mock.calls[2][0]; + s.receive({ ...result, requestId: "different" }); + s.receive({ ...result, sessionId: "old" }); + s.receive({ ...result, results: [] }); + expect(onResult).not.toHaveBeenCalled(); + s.receive(result); + expect(onResult).toHaveBeenCalledWith(result); + s.bridge.error(); + s.receive(s.hostPost.mock.calls[3][0]); + expect(onError).toHaveBeenCalledOnce(); + host.dispose(); + s.receive(result); + expect(onResult).toHaveBeenCalledOnce(); + s.bridge.dispose(); + }, + ); +}); diff --git a/builder-frontend/src/integrations/screenerBridge.ts b/builder-frontend/src/integrations/screenerBridge.ts new file mode 100644 index 00000000..2fef5a4f --- /dev/null +++ b/builder-frontend/src/integrations/screenerBridge.ts @@ -0,0 +1,139 @@ +import type { ScreenerResult } from "@/types"; + +const CHANNEL = "bdt.crm"; +const VERSION = 1; + +// Prefill is JSON form data, never executable content or prototype properties. +function isJsonData(value: unknown, depth = 0): boolean { + if (depth > 30) return false; + if (value === null || typeof value === "string" || typeof value === "boolean") + return true; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) + return value.every((item) => isJsonData(item, depth + 1)); + if (!value || typeof value !== "object") return false; + if (Object.getPrototypeOf(value) !== Object.prototype) return false; + return Object.entries(value).every( + ([key, item]) => + !["__proto__", "prototype", "constructor"].includes(key) && + isJsonData(item, depth + 1), + ); +} + +export interface ScreeningBridge { + start(): void; + dispose(): void; + result(inputData: Record, results: ScreenerResult): void; + error(): void; +} + +/** Opt in using an exact host origin. Only the parent/opener can initialize. */ +export function createScreeningBridge( + screenerId: string, + initialize: (data: Record) => void, + browser: Window = window, +): ScreeningBridge | undefined { + const originParam = new URL(browser.location.href).searchParams.get( + "integrationOrigin", + ); + if (!originParam) return; + + let origin: string; + try { + const url = new URL(originParam); + if ( + url.origin !== originParam || + !["https:", "http:"].includes(url.protocol) + ) + return; + origin = url.origin; + } catch { + return; + } + + const host = browser.parent !== browser ? browser.parent : browser.opener; + if (!host) return; + + const sessionId = Array.from( + browser.crypto.getRandomValues(new Uint8Array(16)), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + let requestId: string | undefined; + let active = false; + let jsonMessages = false; + const send = (type: string, payload: Record = {}) => { + if (!active) return; + const message = { + channel: CHANNEL, + version: VERSION, + type, + screenerId, + sessionId, + requestId, + ...payload, + }; + host.postMessage(jsonMessages ? JSON.stringify(message) : message, origin); + }; + + const receive = (event: MessageEvent) => { + if ( + !active || + requestId || + event.origin !== origin || + event.source !== host + ) + return; + let message = event.data; + if (typeof message === "string") { + try { + message = JSON.parse(message); + } catch { + return; + } + } + if ( + !message || + message.channel !== CHANNEL || + message.version !== VERSION || + message.type !== "initialize" || + message.screenerId !== screenerId || + message.sessionId !== sessionId || + typeof message.requestId !== "string" || + !message.requestId || + message.requestId.length > 200 || + !message.inputData || + Array.isArray(message.inputData) || + !isJsonData(message.inputData) + ) + return; + + requestId = message.requestId; + jsonMessages = typeof event.data === "string"; + initialize(structuredClone(message.inputData)); + send("initialized"); + }; + + return { + start() { + if (active) return; + active = true; + browser.addEventListener("message", receive); + send("ready"); + }, + dispose() { + active = false; + browser.removeEventListener("message", receive); + }, + result(inputData, results) { + if (requestId) + send("result", { + inputData, + results, + evaluatedAt: new Date().toISOString(), + }); + }, + error() { + if (requestId) send("error", { code: "EVALUATION_FAILED" }); + }, + }; +} diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 02cc7b1c..f2c7561a 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -21,6 +21,10 @@ export default defineConfig({ { label: "Developer", items: [ + { + label: "CRM Integrations", + slug: "dev/crm-integrations", + }, { label: "Testing PRs with Codespaces", slug: "dev/testing-prs-with-codespaces", diff --git a/docs/src/content/docs/dev/crm-integrations.md b/docs/src/content/docs/dev/crm-integrations.md new file mode 100644 index 00000000..4970a89f --- /dev/null +++ b/docs/src/content/docs/dev/crm-integrations.md @@ -0,0 +1,235 @@ +--- +title: CRM integrations +description: Prefill a published screener from a CRM and capture its evaluated results. +--- + +Published screeners support CRM integrations through HTTP endpoints and a +versioned browser message protocol. A CRM can embed a screener in an iframe or +open it in a separate window, prefill known answers, and receive evaluated results +to save to the client's record. BDT does not store CRM credentials or write CRM +records; the host application owns its input mapping and result persistence. + +## HTTP endpoints + +These existing public endpoints operate on a **published screener ID**, not the +editable screener's ID. No builder login is required. + +| Method | Path | Purpose | +| ------ | ----------------------------------------------- | -------------------------------------------------- | +| GET | `/api/published/screener/{publishedScreenerId}` | Published screener metadata including `formSchema` | +| POST | `/api/published/{publishedScreenerId}/evaluate` | Evaluate a JSON object of form answers | + +```sh +curl --fail-with-body https://YOUR-API-HOST/api/published/PUBLISHED-ID/evaluate \ + -H 'Content-Type: application/json' \ + -d '{"people":{"client":{"dateOfBirth":"1950-01-01"}},"simpleChecks":{"livesInPhiladelphiaPa":true}}' +``` + +The response is keyed by benefit ID: + +```json +{ + "benefit-id": { + "name": "Example benefit", + "result": "UNABLE_TO_DETERMINE", + "check_results": { + "check-id0": { + "name": "ExampleCheck", + "aliasName": null, + "result": "UNABLE_TO_DETERMINE", + "module": "ExampleModule", + "version": "1.0.0", + "parameters": {}, + "effectiveParameters": {}, + "defaultedParameters": [], + "inputPaths": ["people.client.dateOfBirth"] + } + } + } +} +``` + +Results are `TRUE`, `FALSE`, or `UNABLE_TO_DETERMINE`. They represent eligibility +checks, not a submitted application or an approval. An unknown screener returns +404; evaluation failures return 500. The evaluation endpoint also returns 404 +when the screener has no benefits. The API host can differ from the frontend +host; use the API URL configured for your deployment. + +Use the form's configured keys to map CRM fields. A dotted form key such as +`people.client.dateOfBirth` needs a nested object, not a literal dotted JSON key. +Dates use `YYYY-MM-DD`, booleans use JSON booleans, and numbers use JSON numbers. +Leave unknown answers omitted or `null`. Do not turn missing answers into `false` +or zero. The API transforms the form's keyed `people` object into library input +arrays internally. For checklist inputs, `null` is unknown and `[]` is an explicit +empty selection; the browser form normalizes its checklist answers before evaluation. + +## Browser host adapter + +The reusable ES module is served by the BDT frontend at `/integrations/host.js`. +Copy it into your own application when your CRM requires locally bundled modules. +Create the adapter **before** navigating the iframe or opening the window so it +can receive the screener's ready message. +The adapter adds an opaque `integrationLaunch` marker to reload an existing +iframe for each new session; this marker contains no client information. + +```js +import { createCrmIntegration } from "https://YOUR-BDT-HOST/integrations/host.js"; + +const frame = document.querySelector("iframe"); +const clientId = currentClient.id; // Pin this record for the lifetime of the session. +const connection = createCrmIntegration({ + screenerUrl: "https://YOUR-BDT-HOST/screener/PUBLISHED-ID", + getTargetWindow: () => frame.contentWindow, + initialData: { + people: { client: { dateOfBirth: currentClient.birthDate ?? null } }, + }, + onResult: (message) => { + // Display/store this snapshot locally until the user chooses to save. + showScreeningResults(clientId, { + requestId: message.requestId, + screenerId: message.screenerId, + evaluatedAt: message.evaluatedAt, + results: message.results, + }); + }, + onError: () => showEvaluationError(), +}); +frame.src = connection.url; +// Call connection.dispose() when the host view is closed or its client changes. +``` + +The host helper functions in this snippet (`showScreeningResults` and +`showEvaluationError`) belong to your CRM UI. Downloading and bundling the adapter +also avoids a dependency on cross-origin module fetching permissions. + +For a separate tab/window, keep a reference to the opened window: + +```js +let popup; +const connection = createCrmIntegration({ + screenerUrl: "https://YOUR-BDT-HOST/screener/PUBLISHED-ID", + getTargetWindow: () => popup, + initialData: {}, + onResult: (message) => showScreeningResults(clientId, message), +}); +popup = window.open(connection.url, "_blank"); // Run from a user click. +if (!popup) connection.dispose(); // Show a popup-blocked message in your CRM. +``` + +The separate-window workflow needs `window.opener`: don't use `noopener`, +`noreferrer`, or a link opened manually in another tab. Deployment headers such +as Cross-Origin-Opener-Policy can sever this relationship. Use the iframe workflow +if your environment requires opener isolation. An iframe needs a hosting policy +that permits the CRM origin in CSP `frame-ancestors`, and a CRM policy permitting +the BDT origin in `frame-src`. CORS alone does not permit framing. + +## Message protocol (version 1) + +For custom integrations, navigate to: + +```text +https://YOUR-BDT-HOST/screener/PUBLISHED-ID?integrationOrigin=https%3A%2F%2FYOUR-CRM-HOST +``` + +`integrationOrigin` must be an exact HTTP(S) origin, including the port when +needed, with no trailing slash or path. Use HTTPS in production. It opts the +page into communication with its direct parent (iframe) or opener (window). +Both sides must check `event.origin` and `event.source`. Send to an exact +`targetOrigin`; never use `"*"`. No applicant data or CRM credentials belong in +the URL. The configured origin is a messaging destination, not CRM authentication; +your host/backend still authorizes reads and writes to client records. + +Every message is a plain object with these envelope fields: + +| Field | Value | +| ------------ | ------------------------------------------------------------------------------------- | +| `channel` | `"bdt.crm"` | +| `version` | `1` | +| `type` | One of the types below | +| `screenerId` | Published screener ID | +| `sessionId` | Random ID generated by the screener for this page load | +| `requestId` | Host's nonempty opaque correlation string (maximum 200 characters), except on `ready` | + +| Direction | Type | Additional fields / behavior | +| --------------- | ------------- | ------------------------------------------------------------- | +| Screener → host | `ready` | Host can now send prefill using this `sessionId` | +| Host → screener | `initialize` | `inputData`: a JSON form-data object; use `{}` for no prefill | +| Screener → host | `initialized` | Prefill accepted; form loads and evaluates | +| Screener → host | `result` | `inputData`, `results`, `evaluatedAt` (ISO 8601 timestamp) | +| Screener → host | `error` | `code: "EVALUATION_FAILED"` | + +The screener waits for initialization before displaying the connected form. It +accepts one initialization per page load and rejects malformed data, unsafe +prototype keys, mismatched origins/windows, and mismatched sessions. To start +another client's screening, create a fresh connection and navigate the frame or +window again. Without a valid origin and parent/opener, it runs as an ordinary +standalone screener and sends no integration messages. + +`result` is sent after initial evaluation and after evaluated edits (normally +debounced by one second). Multiple results per session are expected. Older +in-flight responses are discarded once a newer evaluation has started. A +result is a snapshot, not a completion event. Offer a host-side **Save results** +action or implement your application's explicit autosave policy. Do not treat +the first result as final. A successful new evaluation clears the host's error +state; after an error, don't save the old snapshot as the latest result. + +Pin the CRM record in the host's session rather than trusting a record ID in a +result payload. On save, authorize that record through your CRM backend or its +native authenticated API, and validate whichever result fields you persist. +Use the opaque request ID to correlate a session, not as an authentication token. +For audit history, save separate screening records with timestamps and the +published screener ID. Browser messages are client-provided data; integrations +requiring trusted decisions should re-evaluate the answers server-side. + +## Examples and adapter extension points + +- **Browser demo:** run `node examples/crm/browser/serve.mjs` from the repository + root, then open `http://127.0.0.1:4174/demo.html`. Enter a published BDT screener + URL and test both iframe and separate-window modes. Individual client fields + use a flat CRM schema; an explicit mapper converts them to nested screener data, + including monthly-to-annual income conversion. **Inspect field mapping** shows + the source record and generated prefill side by side. It prefills synthetic data + and lets you save results to a browser-local demo record. Its record panel shows + benefit statuses and timestamps, and restores saved results after a reload. + Client fields and saved results share one record section; the embedded screener + uses a full-width area with questions and live results side by side. + The demo lives under `examples/crm/browser/` and is not included in frontend deployments; its local + server serves the same canonical adapter used by the frontend. +- **Salesforce:** `examples/crm/salesforce/` contains a community nonprofit client + app with editable client profiles, screening intake, full Philadelphia example + prefill, explicit result saving, and formatted saved-results cards. Intake + includes income, housing, property taxes, age, family, and program enrollment; + unanswered values remain unknown. Sales fields and actions are excluded from + the custom client page. Follow its README to configure a development org, + permissions, and your published screener. The adapter supports both iframe + and separate-window screening. Verify your own screener's mapping. + +Salesforce supports cross-origin iframe messaging under +[Lightning Web Security](https://developer.salesforce.com/docs/platform/lightning-components-security/guide/lws-iframes.html). +The example uses native +[getRecord](https://developer.salesforce.com/docs/platform/lwc/guide/reference-wire-adapters-record.html) +and [updateRecord](https://developer.salesforce.com/docs/platform/lwc/guide/reference-update-record.html) +for authenticated record access. Its adapter is copied into a local LWC module +because Salesforce restricts +[third-party script loading](https://developer.salesforce.com/docs/platform/lwc/guide/js-api-calls). + +Other CRMs can reuse the same adapter by providing their own input mapper and +`onResult`/`onError` callbacks. Those callbacks are the extension points for CRM +UI and storage; there is no provider registry or server-side CRM plugin runtime. + +## Local verification + +With the frontend dev server running and its example environment configured, +run `npx playwright test --project=crm-integrations` from `e2e/`. These Chromium +tests intercept the published APIs and verify prefill, edited results, explicit +saving, failure recovery, and standalone behavior. Playwright automatically starts +the browser example server on port 4174, or reuses an already running server +locally. They require no emulators or CRM account. Frontend unit tests also cover +origin/source validation, correlation, one-time initialization, outdated responses, +and listener cleanup. + +Salesforce enables the host adapter's `serializeMessages: true` option to send +JSON envelopes across Lightning Web Security sandbox boundaries. The screener +parses that initialization and returns JSON envelopes for the session. Object +messages remain the default for other hosts; both formats use the same exact +origin, source, session, and request checks. diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 4409fd04..177198f9 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -30,6 +30,13 @@ export default defineConfig({ screenshot: "only-on-failure", }, + // The CRM host example lives outside frontend deployment assets. + webServer: { + command: 'node ../examples/crm/browser/serve.mjs', + url: 'http://127.0.0.1:4174/demo.html', + reuseExistingServer: !process.env.CI, + }, + /* Configure projects for major browsers */ projects: [ { @@ -40,12 +47,20 @@ export default defineConfig({ // Main test project - depends on setup for authentication { name: 'chromium', + testIgnore: /crmIntegration\.spec\.ts/, use: { browserName: 'chromium', }, dependencies: ['setup'], }, + // Uses public screeners with intercepted APIs; no emulator/login setup. + { + name: 'crm-integrations', + testMatch: /crmIntegration\.spec\.ts/, + use: { browserName: 'chromium' }, + }, + // TODO: Update config to use other non-chromium browsers // { diff --git a/e2e/tests/crmIntegration.spec.ts b/e2e/tests/crmIntegration.spec.ts new file mode 100644 index 00000000..1bab4557 --- /dev/null +++ b/e2e/tests/crmIntegration.spec.ts @@ -0,0 +1,236 @@ +import { expect, test } from "@playwright/test"; + +const demoUrl = "http://127.0.0.1:4174/demo.html"; + +// Public browser integration tests use intercepted API responses, not Firebase. +test.beforeEach(async ({ context }) => { + await context.route("**/api/published/**", async (route) => { + const request = route.request(); + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + }; + if (request.method() === "OPTIONS") { + await route.fulfill({ status: 204, headers }); + } else if (request.method() === "GET") { + await route.fulfill({ + headers, + json: { + screenerName: "CRM test screener", + formSchema: { + type: "default", + id: "crm-form", + schemaVersion: 18, + components: [ + { + type: "textfield", + id: "birth-date", + key: "people.client.dateOfBirth", + label: "Birthdate", + }, + { + type: "number", + id: "income", + key: "custom.householdIncome", + label: "Household income", + }, + { + type: "checkbox", + id: "resident", + key: "simpleChecks.livesInPhiladelphiaPa", + label: "Philadelphia resident", + }, + { + type: "checkbox", + id: "cash-interest", + key: "custom.wantsExtraCash", + label: "Interested in cash assistance", + }, + ], + }, + }, + }); + } else { + const input = request.postDataJSON(); + await route.fulfill({ + headers, + json: { + benefit: { + name: "Example benefit", + result: input.custom.householdIncome <= 40000 ? "TRUE" : "FALSE", + check_results: {}, + }, + }, + }); + } + }); +}); + +for (const serializeMessages of [false, true]) { + for (const mode of ["iframe", "popup"] as const) { + test(`CRM prefill, edited results and explicit record save through ${mode} with ${serializeMessages ? "JSON" : "object"} messages`, async ({ + page, + baseURL, + context, + }) => { + if (serializeMessages) { + await context.route( + new URL("/demo.js", demoUrl).href, + async (route) => { + const response = await route.fetch(); + const body = (await response.text()).replace( + "createCrmIntegration({", + "createCrmIntegration({ serializeMessages: true,", + ); + await route.fulfill({ response, body }); + }, + ); + } + await page.goto(demoUrl); + const record = page.getByRole("region", { name: "Demo client record" }); + // The host and frontend use different ports, exercising cross-origin messaging. + const screenerUrl = new URL("/screener/crm-test", baseURL); + await page.locator("#screener-url").fill(screenerUrl.href); + await record + .getByLabel("Date of birth", { exact: true }) + .fill("1960-02-03"); + await record.getByLabel("Monthly household income ($)").fill("2750.25"); + await record.getByLabel("City of residence").fill("Philadelphia"); + await record + .getByLabel("Cash assistance interest") + .selectOption("requested"); + await page.getByText("Inspect field mapping", { exact: true }).click(); + await expect(page.locator("#client-json")).toContainText( + '"monthly_income": 2750.25', + ); + await expect(page.locator("#prefill-json")).toContainText( + '"householdIncome": 33003', + ); + await page.getByText("Inspect field mapping", { exact: true }).click(); + const evaluationRequest = context.waitForEvent( + "request", + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === + "/api/published/crm-test/evaluate", + ); + const popupPromise = + mode === "popup" ? page.waitForEvent("popup") : undefined; + await page.locator(mode === "popup" ? "#popup" : "#embed").click(); + const screener = popupPromise + ? await popupPromise + : page.frameLocator("#screener"); + const income = screener.getByLabel("Household income"); + await expect(income).toHaveValue("33003", { timeout: 15000 }); + await expect( + screener.getByLabel("Birthdate", { exact: true }), + ).toHaveValue("1960-02-03"); + await expect(screener.getByLabel("Philadelphia resident")).toBeChecked(); + await expect( + screener.getByLabel("Interested in cash assistance"), + ).toBeChecked(); + const input = (await evaluationRequest).postDataJSON(); + expect(input).toMatchObject({ + people: { client: { dateOfBirth: "1960-02-03" } }, + custom: { householdIncome: 33003, wantsExtraCash: true }, + simpleChecks: { livesInPhiladelphiaPa: true }, + }); + expect(input).not.toHaveProperty("monthly_income"); + expect(input).not.toHaveProperty("full_name"); + await expect(page.locator("#status")).toContainText( + "Unsaved results: 1 eligible, 0 ineligible", + ); + if (mode === "iframe") { + // A narrow host scrolls the iframe instead of stacking live results. + await page.setViewportSize({ width: 900, height: 800 }); + const frameBox = await page.locator("#screener").boundingBox(); + const inputBox = await income.boundingBox(); + const resultsBox = await screener + .getByRole("heading", { name: "Eligibility Results", exact: true }) + .boundingBox(); + expect(frameBox!.width).toBeGreaterThanOrEqual(1100); + expect(resultsBox!.x).toBeGreaterThanOrEqual( + inputBox!.x + inputBox!.width, + ); + expect(resultsBox!.y).toBeLessThan(inputBox!.y + inputBox!.height); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeLessThanOrEqual(900); + await page.setViewportSize({ width: 1280, height: 720 }); + } + await expect(record).toContainText("No screening saved"); + await income.fill("50000"); + await expect(page.locator("#status")).toContainText( + "Unsaved results: 0 eligible, 1 ineligible", + ); + await expect(record).toContainText("No screening saved"); + await page.locator("#save").click(); + await expect(record).toContainText("Screening saved"); + await expect(record).toContainText("Example benefit"); + await expect( + record.getByText("Ineligible", { exact: true }), + ).toBeVisible(); + await expect(record.locator("#saved-screener")).toHaveText("crm-test"); + await expect(record.locator("#saved-at")).toHaveAttribute( + "datetime", + /.+/, + ); + await context.route( + "**/api/published/crm-test/evaluate", + async (route) => { + if ( + route.request().method() === "POST" && + route.request().postDataJSON().custom.householdIncome === 60000 + ) { + await route.fulfill({ + status: 500, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + } else { + await route.fallback(); + } + }, + ); + await income.fill("60000"); + await expect(page.locator("#status")).toContainText("Evaluation failed"); + await expect(page.locator("#save")).toBeDisabled(); + await expect( + record.getByText("Ineligible", { exact: true }), + ).toBeVisible(); + await income.fill("35000"); + await expect(page.locator("#status")).toContainText( + "Unsaved results: 1 eligible, 0 ineligible", + ); + await expect(page.locator("#save")).toBeEnabled(); + // New evaluations do not overwrite the saved record until explicitly saved. + await expect( + record.getByText("Ineligible", { exact: true }), + ).toBeVisible(); + await page.locator("#save").click(); + await expect(record.getByText("Eligible", { exact: true })).toBeVisible(); + const savedAt = await record + .locator("#saved-at") + .getAttribute("datetime"); + await page.reload(); + await expect(record.getByText("Eligible", { exact: true })).toBeVisible(); + await expect(record.locator("#saved-at")).toHaveAttribute( + "datetime", + savedAt!, + ); + await expect(page.locator("#save")).toBeDisabled(); + await record.getByRole("button", { name: "Clear saved results" }).click(); + await page.reload(); + await expect(record).toContainText("No screening saved"); + }); + } +} + +test("standalone screener still evaluates edited answers", async ({ page }) => { + await page.goto("/screener/crm-test"); + await expect( + page.getByText("Waiting for information from the connected application"), + ).toBeHidden(); + await page.getByLabel("Household income").fill("30000"); + await expect(page.getByText("Eligible", { exact: true })).toBeVisible(); +}); diff --git a/examples/crm/browser/README.md b/examples/crm/browser/README.md new file mode 100644 index 00000000..910a1ff6 --- /dev/null +++ b/examples/crm/browser/README.md @@ -0,0 +1,82 @@ +# Browser CRM integration example + +This standalone host demonstrates prefill, iframe and popup screening, and an +explicit **Save results** action. Its **Demo client record** panel shows readable +benefit statuses and the saved and evaluated timestamps. The fictional record is +stored in this browser's local storage, and reloading restores its saved snapshot. +It is an example, and is not included in BDT frontend deployments. + +Client information, its mapping preview, and saved screening results share one +**Demo client record** section. **Run a screening** occupies its own full-width +section below the record. The embedded screener has a minimum viewport width of +1100 pixels so live results appear to the right of the questions. Smaller host +windows scroll the screener horizontally. **View client record** returns to the +combined record section after reviewing or saving results. + +From the repository root, run: + +```sh +node examples/crm/browser/serve.mjs +``` + +Open . Enter a published screener URL from a BDT +frontend that includes the CRM bridge, then edit the individual synthetic client +fields. Use **Screen here** or **Screen in new window**, review or edit the +answers, and choose **Save results to demo record** after results arrive. Inspect +the **Demo client record** panel, or reload the page, to see the saved snapshot. +Later screening changes leave that snapshot unchanged until you save again. +Use **Clear saved results** to reset the demo record. + +Only the result snapshot is stored, not the prefilled answers. Storage is local to +this browser and example origin; there is no CRM backend or server persistence. + +## Map client fields to screener answers + +The demo CRM uses a flat record with its own field names, such as `birth_date`, +`home_city`, and `monthly_income`. `clientMapping.mjs` explicitly maps these +fields into the Philadelphia example's nested screener hierarchy: + +| CRM field | Screener path | Conversion | +| ------------------------ | ------------------------------------ | --------------------------------------------------------- | +| `birth_date` | `people.client.dateOfBirth` | ISO date; blank becomes null | +| `home_city` | `simpleChecks.livesInPhiladelphiaPa` | Philadelphia becomes true, another city false, blank null | +| `monthly_income` | `custom.householdIncome` | Monthly dollars × 12, rounded to cents; blank stays null | +| `cash_support_requested` | `custom.wantsExtraCash` | requested/declined become true/false; unknown stays null | + +**Inspect field mapping** shows this table and read-only previews of both shapes. +The previews update as you edit client fields. Client ID and name stay in the CRM; +only mapped screening answers are sent to BDT. Income of zero remains zero. +A launch takes the current mapped answers; editing client fields does not change +an already-open screening or the saved result snapshot. + +The mapper is specific to the Philadelphia example. Adapt it for the configured +keys in another published screener's form. Run its mapping checks from the repo root: + +```sh +node examples/crm/browser/tests/clientMapping.test.mjs +``` + +## Frontend and local server + +The URL defaults to the local frontend at `http://localhost:5173`; start it +separately with `npm run dev` in `builder-frontend/`, or enter your deployed BDT +screener URL. The published screener must exist and its evaluation API must be +available. Popup mode requires popups to be allowed. + +The local server binds to loopback on port 4174 and serves only `demo.html`, +`demo.js`, `clientMapping.mjs`, and the canonical adapter from +`builder-frontend/public/integrations/host.js`. It makes the adapter available as +`/host.js` for the example's relative import, without maintaining a duplicate. +The production frontend continues to serve the adapter at `/integrations/host.js`. + +With the local frontend running, run the browser checks from `e2e/`: + +```sh +npx playwright test --project=crm-integrations +``` + +Playwright starts this example server automatically, or reuses it locally if +already running. Restart a manually running example server after adding assets. +The tests verify that flat client fields reach the screener as mapped nested data, +including monthly-to-annual income conversion. They intercept published API responses, so they require no +Firebase emulators, login, published screener, or CRM account. diff --git a/examples/crm/browser/clientMapping.mjs b/examples/crm/browser/clientMapping.mjs new file mode 100644 index 00000000..29147a90 --- /dev/null +++ b/examples/crm/browser/clientMapping.mjs @@ -0,0 +1,37 @@ +// The demo CRM uses flat field names and monthly income. The Philadelphia +// screener expects nested people/custom/simpleChecks data and annual income. +export function clientToScreenerData(client) { + const city = + typeof client.home_city === "string" ? client.home_city.trim() : ""; + const birthDate = + typeof client.birth_date === "string" ? client.birth_date.trim() : ""; + const monthlyIncome = client.monthly_income; + const annualIncome = + typeof monthlyIncome === "number" && + Number.isFinite(monthlyIncome) && + monthlyIncome >= 0 + ? Math.round(monthlyIncome * 12 * 100) / 100 + : null; + + return { + people: { + client: { + dateOfBirth: /^\d{4}-\d{2}-\d{2}$/.test(birthDate) ? birthDate : null, + }, + }, + custom: { + householdIncome: Number.isFinite(annualIncome) ? annualIncome : null, + wantsExtraCash: + client.cash_support_requested === "requested" + ? true + : client.cash_support_requested === "declined" + ? false + : null, + }, + simpleChecks: { + livesInPhiladelphiaPa: city + ? city.toLowerCase() === "philadelphia" + : null, + }, + }; +} diff --git a/examples/crm/browser/demo.html b/examples/crm/browser/demo.html new file mode 100644 index 00000000..f67f6a81 --- /dev/null +++ b/examples/crm/browser/demo.html @@ -0,0 +1,358 @@ + + + + + + BDT CRM integration example + + + + +

CRM screening example

+

+ Screen a fictional client, then save the results back to their demo + record. +

+
+
+

Demo client record

+

Alex Example · Client ID: demo-client-1

+
+
+
+ Client information +

+ These are fields in the demo CRM. The mapper translates them + into answers for the Philadelphia example screener when you + start screening. Leave unanswered fields blank or Unknown. +

+
+ + + + +
+
+
+ Inspect field mapping + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CRM fieldScreener pathConversion
birth_datepeople.client.dateOfBirthISO date; blank → null
home_citysimpleChecks.livesInPhiladelphiaPa + Philadelphia → true; another city → false; blank → null +
monthly_incomecustom.householdIncomeMonthly × 12; blank → null
cash_support_requestedcustom.wantsExtraCashrequested / declined → true / false; unknown → null
+
+
+

CRM client · flat fields

+

+                
+
+

Screener prefill · nested data

+

+                
+
+

+ Client ID and name stay in the CRM. Only mapped answers are sent + to the screener. +

+
+
+
+

Saved screening

+
+
Screening status
+
No screening saved
+
+ +

+ Save a screening to see this client’s benefit results. +

+
    +

    + This record is saved in this browser and restored when you reload + the page. New screening results update it only when you choose + Save. +

    + +
    +
    +
    +
    +

    Run a screening

    + +
    + + + +
    +

    View client record

    +

    + Enter a published screener URL to start. +

    + +
    +
    + + diff --git a/examples/crm/browser/demo.js b/examples/crm/browser/demo.js new file mode 100644 index 00000000..0c2c2944 --- /dev/null +++ b/examples/crm/browser/demo.js @@ -0,0 +1,196 @@ +import { createCrmIntegration } from "./host.js"; +import { clientToScreenerData } from "./clientMapping.mjs"; + +const element = (id) => document.getElementById(id); +const frame = element("screener"); +const frameViewport = element("screener-viewport"); +const client = { id: "demo-client-1", name: "Alex Example", screening: null }; +const storageKey = "bdt.crm.browser-demo.record.v1"; +let connection; +let popup; +let latest; +element("screener-url").value = "http://localhost:5173/screener/PUBLISHED-ID"; + +function readClientFields() { + const monthlyIncome = element("monthly-income").valueAsNumber; + return { + id: client.id, + full_name: client.name, + birth_date: element("birth-date").value || null, + home_city: element("home-city").value.trim() || null, + monthly_income: Number.isFinite(monthlyIncome) ? monthlyIncome : null, + cash_support_requested: element("cash-interest").value, + }; +} + +function renderMapping() { + const source = readClientFields(); + element("client-json").textContent = JSON.stringify(source, null, 2); + element("prefill-json").textContent = JSON.stringify( + clientToScreenerData(source), + null, + 2, + ); +} + +element("client-fields").addEventListener("input", renderMapping); +renderMapping(); + +function readRecord() { + try { + const stored = JSON.parse(localStorage.getItem(storageKey)); + if ( + stored?.id === client.id && + stored.screening?.results && + typeof stored.screening.results === "object" && + !Array.isArray(stored.screening.results) + ) { + return { ...client, screening: stored.screening }; + } + } catch { + // Missing or invalid browser storage leaves an empty demo record. + } + return client; +} + +function renderTime(id, value) { + const time = element(id); + const date = new Date(value); + if (typeof value === "string" && Number.isFinite(date.getTime())) { + time.dateTime = date.toISOString(); + time.textContent = date.toLocaleString(); + } else { + time.removeAttribute("datetime"); + time.textContent = "Unavailable"; + } +} + +function renderRecord() { + // Read the stored record, rather than displaying the latest unsaved message. + const snapshot = readRecord().screening; + element("record-state").textContent = snapshot + ? "Screening saved" + : "No screening saved"; + element("saved-details").hidden = !snapshot; + element("record-empty").hidden = !!snapshot; + element("clear-record").disabled = !snapshot; + element("saved-results").replaceChildren(); + if (!snapshot) return; + renderTime("saved-at", snapshot.savedAt); + renderTime("evaluated-at", snapshot.evaluatedAt); + element("saved-screener").textContent = snapshot.screenerId; + for (const [id, benefit] of Object.entries(snapshot.results)) { + if (!benefit || typeof benefit !== "object") continue; + const item = document.createElement("li"); + item.className = "benefit"; + const name = document.createElement("h3"); + name.textContent = typeof benefit.name === "string" ? benefit.name : id; + const status = document.createElement("span"); + const outcome = + benefit.result === "TRUE" + ? { label: "Eligible", style: "eligible" } + : benefit.result === "FALSE" + ? { label: "Ineligible", style: "ineligible" } + : { label: "Needs information", style: "unknown" }; + status.className = `outcome ${outcome.style}`; + status.textContent = outcome.label; + item.append(name, status); + element("saved-results").append(item); + } +} + +renderRecord(); + +function start(popupMode) { + if (!element("monthly-income").reportValidity()) return; + connection?.dispose(); + frame.hidden = true; + frameViewport.hidden = true; + frame.removeAttribute("src"); + latest = undefined; + element("save").disabled = true; + try { + connection = createCrmIntegration({ + screenerUrl: element("screener-url").value, + getTargetWindow: () => (popupMode ? popup : frame.contentWindow), + initialData: clientToScreenerData(readClientFields()), + onResult: (message) => { + latest = message; + const counts = { eligible: 0, ineligible: 0, unknown: 0 }; + for (const benefit of Object.values(message.results)) { + counts[ + benefit?.result === "TRUE" + ? "eligible" + : benefit?.result === "FALSE" + ? "ineligible" + : "unknown" + ]++; + } + element("save").disabled = false; + element("status").textContent = + `Unsaved results: ${counts.eligible} eligible, ${counts.ineligible} ineligible, ${counts.unknown} need information. ` + + "Review them in the screener, then save to the demo record."; + }, + onError: () => { + latest = undefined; + element("save").disabled = true; + element("status").textContent = + "Evaluation failed. Change an answer to retry."; + }, + }); + element("status").textContent = "Waiting for screening results…"; + if (popupMode) { + popup = window.open(connection.url, "_blank"); + if (!popup) { + connection.dispose(); + element("status").textContent = "Allow popups, then try again."; + } + } else { + frame.hidden = false; + frameViewport.hidden = false; + frame.src = connection.url; + } + } catch (error) { + element("status").textContent = error.message; + } +} + +element("embed").addEventListener("click", () => start(false)); +element("popup").addEventListener("click", () => start(true)); +element("save").addEventListener("click", () => { + if (!latest) return; + try { + localStorage.setItem( + storageKey, + JSON.stringify({ + ...client, + screening: { + requestId: latest.requestId, + screenerId: latest.screenerId, + evaluatedAt: latest.evaluatedAt, + savedAt: new Date().toISOString(), + results: latest.results, + }, + }), + ); + renderRecord(); + element("save").disabled = true; + element("status").textContent = "Results saved to the demo client record."; + } catch { + element("status").textContent = + "Could not save the demo record in this browser. Try again."; + } +}); +element("clear-record").addEventListener("click", () => { + try { + localStorage.removeItem(storageKey); + renderRecord(); + element("save").disabled = !latest; + element("status").textContent = + "Saved results cleared from the demo record."; + } catch { + element("status").textContent = + "Could not clear the demo record in this browser. Try again."; + } +}); +window.addEventListener("pagehide", () => connection?.dispose()); diff --git a/examples/crm/browser/serve.mjs b/examples/crm/browser/serve.mjs new file mode 100644 index 00000000..ff8e73d4 --- /dev/null +++ b/examples/crm/browser/serve.mjs @@ -0,0 +1,48 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; + +// Serve only the example and the canonical adapter; don't expose the repository. +const assets = new Map([ + ["/", [new URL("./demo.html", import.meta.url), "text/html"]], + ["/demo.html", [new URL("./demo.html", import.meta.url), "text/html"]], + ["/demo.js", [new URL("./demo.js", import.meta.url), "text/javascript"]], + [ + "/clientMapping.mjs", + [new URL("./clientMapping.mjs", import.meta.url), "text/javascript"], + ], + [ + "/host.js", + [ + new URL( + "../../../builder-frontend/public/integrations/host.js", + import.meta.url, + ), + "text/javascript", + ], + ], +]); + +createServer(async (request, response) => { + if (request.method !== "GET" && request.method !== "HEAD") { + response.writeHead(405, { Allow: "GET, HEAD" }).end(); + return; + } + const asset = assets.get(new URL(request.url, "http://localhost").pathname); + if (!asset) { + response.writeHead(404).end(); + return; + } + try { + const content = await readFile(asset[0]); + response.writeHead(200, { + "Content-Type": `${asset[1]}; charset=utf-8`, + "Cache-Control": "no-store", + }); + response.end(request.method === "HEAD" ? undefined : content); + } catch (error) { + console.error("Could not serve CRM example asset", error); + response.writeHead(500).end(); + } +}).listen(4174, "127.0.0.1", () => { + console.log("CRM browser example: http://127.0.0.1:4174/demo.html"); +}); diff --git a/examples/crm/browser/tests/clientMapping.test.mjs b/examples/crm/browser/tests/clientMapping.test.mjs new file mode 100644 index 00000000..ff06ec75 --- /dev/null +++ b/examples/crm/browser/tests/clientMapping.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { clientToScreenerData } from "../clientMapping.mjs"; + +test("flat CRM fields map into three screener branches and annual income", () => { + assert.deepEqual( + clientToScreenerData({ + id: "client-123", + full_name: "Synthetic client", + case_notes: "Keep this in the CRM", + birth_date: "1960-02-03", + home_city: " Philadelphia ", + monthly_income: 2750.25, + cash_support_requested: "requested", + }), + { + people: { client: { dateOfBirth: "1960-02-03" } }, + custom: { householdIncome: 33003, wantsExtraCash: true }, + simpleChecks: { livesInPhiladelphiaPa: true }, + }, + ); +}); + +test("missing and unanswered CRM fields stay unknown", () => { + const expected = { + people: { client: { dateOfBirth: null } }, + custom: { householdIncome: null, wantsExtraCash: null }, + simpleChecks: { livesInPhiladelphiaPa: null }, + }; + assert.deepEqual(clientToScreenerData({}), expected); + assert.deepEqual( + clientToScreenerData({ + birth_date: "", + home_city: " ", + monthly_income: null, + cash_support_requested: "unknown", + }), + expected, + ); +}); + +test("zero income and explicit negative answers are preserved", () => { + const data = clientToScreenerData({ + home_city: "Pittsburgh", + monthly_income: 0, + cash_support_requested: "declined", + }); + assert.equal(data.custom.householdIncome, 0); + assert.equal(data.custom.wantsExtraCash, false); + assert.equal(data.simpleChecks.livesInPhiladelphiaPa, false); +}); + +test("income conversion rounds to cents and rejects unusable amounts", () => { + assert.equal( + clientToScreenerData({ monthly_income: 3333.33 }).custom.householdIncome, + 39999.96, + ); + for (const monthly_income of ["2500", -1, NaN, Infinity, Number.MAX_VALUE]) { + assert.equal( + clientToScreenerData({ monthly_income }).custom.householdIncome, + null, + ); + } +}); diff --git a/examples/crm/salesforce/.prettierrc.json b/examples/crm/salesforce/.prettierrc.json new file mode 100644 index 00000000..8a6b62a5 --- /dev/null +++ b/examples/crm/salesforce/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "overrides": [ + { + "files": "*.html", + "options": { "parser": "lwc" } + } + ] +} diff --git a/examples/crm/salesforce/BDT_Client_Record.flexipage-meta.xml b/examples/crm/salesforce/BDT_Client_Record.flexipage-meta.xml new file mode 100644 index 00000000..bcda4aed --- /dev/null +++ b/examples/crm/salesforce/BDT_Client_Record.flexipage-meta.xml @@ -0,0 +1,64 @@ + + + Community nonprofit client intake, benefit screening, and saved eligibility results. + + + + c:bdtClientHeader + clientHeader + + + header + Region + + + + + + variant + screening + + c:bdtClientIntake + screeningIntake + + + + + + screenerUrl + https://YOUR-BDT-HOST/screener/PUBLISHED-ID + + c:bdtScreening + clientScreening + + + + + c:bdtScreeningResults + savedClientResults + + + main + Region + + + + + + variant + profile + + c:bdtClientIntake + clientProfile + + + sidebar + Region + + BDT Client Record + Contact + + RecordPage + \ No newline at end of file diff --git a/examples/crm/salesforce/BDT_Screening.permissionset-meta.xml b/examples/crm/salesforce/BDT_Screening.permissionset-meta.xml new file mode 100644 index 00000000..9c401751 --- /dev/null +++ b/examples/crm/salesforce/BDT_Screening.permissionset-meta.xml @@ -0,0 +1,120 @@ + + + Read and update client intake and benefit screening results in the developer demo. + + true + Contact.Birthdate + true + + + true + Contact.BDT_Client_Notes__c + true + + + true + Contact.MailingAddress + true + + + true + Contact.Email + true + + + true + Contact.Phone + true + + + true + Contact.BDT_Wants_Cash__c + true + + + true + Contact.BDT_Tax_Delinquent__c + true + + + true + Contact.BDT_Tax_Abatement__c + true + + + true + Contact.BDT_Spouse_Birthdate__c + true + + + true + Contact.BDT_Preferred_Contact__c + true + + + true + Contact.BDT_Philly_Resident__c + true + + + true + Contact.BDT_Owner_Occupant__c + true + + + true + Contact.BDT_Late_Spouse_65__c + true + + + true + Contact.BDT_Intake_Date__c + true + + + true + Contact.BDT_Household_Size__c + true + + + true + Contact.BDT_Household_Income__c + true + + + true + Contact.BDT_Has_Spouse__c + true + + + true + Contact.BDT_Enrollment_Review__c + true + + + true + Contact.BDT_Enrolled_Programs__c + true + + + true + Contact.BDT_Client_Status__c + true + + + true + Contact.BDT_Screening__c + true + + false + + + false + false + true + true + false + Contact + false + + \ No newline at end of file diff --git a/examples/crm/salesforce/BDT_Screening__c.field-meta.xml b/examples/crm/salesforce/BDT_Screening__c.field-meta.xml new file mode 100644 index 00000000..984c218d --- /dev/null +++ b/examples/crm/salesforce/BDT_Screening__c.field-meta.xml @@ -0,0 +1,8 @@ + + + BDT_Screening__c + + LongTextArea + 131072 + 10 + diff --git a/examples/crm/salesforce/Community_Client_Services.app-meta.xml b/examples/crm/salesforce/Community_Client_Services.app-meta.xml new file mode 100644 index 00000000..86deb11f --- /dev/null +++ b/examples/crm/salesforce/Community_Client_Services.app-meta.xml @@ -0,0 +1,22 @@ + + + + View + Action override created by Lightning App Builder during activation. + BDT_Client_Record + Large + false + Flexipage + Contact + + Client intake, benefits navigation, and follow-up for a community-based nonprofit. + Large + false + false + false + false + + Standard + standard-Contact + Lightning + diff --git a/examples/crm/salesforce/Community_Clients.listView-meta.xml b/examples/crm/salesforce/Community_Clients.listView-meta.xml new file mode 100644 index 00000000..c412d1d0 --- /dev/null +++ b/examples/crm/salesforce/Community_Clients.listView-meta.xml @@ -0,0 +1,12 @@ + + + Community_Clients + FULL_NAME + BDT_Client_Status__c + BDT_Intake_Date__c + BDT_Household_Size__c + BDT_Preferred_Contact__c + Everything + BDT_Client_Status__cnotEqual + + diff --git a/examples/crm/salesforce/Contact-BDT Client Layout.layout-meta.xml b/examples/crm/salesforce/Contact-BDT Client Layout.layout-meta.xml new file mode 100644 index 00000000..58be1941 --- /dev/null +++ b/examples/crm/salesforce/Contact-BDT Client Layout.layout-meta.xml @@ -0,0 +1,118 @@ + + + + true + true + true + + + + Required + Name + + + Edit + Email + + + Edit + BDT_Client_Status__c + + + Edit + BDT_Preferred_Contact__c + + + Edit + BDT_Client_Notes__c + + + Edit + AccountId + + + + + Edit + Phone + + + Edit + MailingAddress + + + Edit + BDT_Intake_Date__c + + + Edit + BDT_Household_Size__c + + + + + + true + true + true + + + + Edit + Birthdate + + + Edit + BDT_Household_Income__c + + + Edit + BDT_Owner_Occupant__c + + + Edit + BDT_Tax_Abatement__c + + + Edit + BDT_Spouse_Birthdate__c + + + Edit + BDT_Enrollment_Review__c + + + + + Edit + BDT_Philly_Resident__c + + + Edit + BDT_Wants_Cash__c + + + Edit + BDT_Tax_Delinquent__c + + + Edit + BDT_Has_Spouse__c + + + Edit + BDT_Late_Spouse_65__c + + + Edit + BDT_Enrolled_Programs__c + + + + + false + false + false + false + false + \ No newline at end of file diff --git a/examples/crm/salesforce/README.md b/examples/crm/salesforce/README.md new file mode 100644 index 00000000..b035a947 --- /dev/null +++ b/examples/crm/salesforce/README.md @@ -0,0 +1,119 @@ +# Salesforce community client services demo + +This example uses Salesforce Contacts as nonprofit **client records**. Its client +page replaces sales highlights and Record Detail with a client header, an editable +client profile, screening intake, and formatted saved screening results. The +**Community Client Services** app gives staff a focused place to open clients. +Its **Community clients** directory shows records with a client service status, +with intake dates, household size, and preferred contact method. +Title, Department, Lead Source, Reports To, Assistant, Fax, and sales actions are +excluded from the client page. + +The client profile tracks contact information, mailing address, preferred contact +method, intake date, service status, household size, and client goals and follow-up +notes. Contact ownership identifies the assigned staff member. The screening +intake matches the published **Philadelphia Benefits Example**: + +| Client intake | Screener data | +| ---------------------------------------- | --------------------------------------------------- | +| Birthdate | `people.client.dateOfBirth` | +| Annual household income | `custom.householdIncome` | +| Interested in cash assistance | `custom.wantsExtraCash` | +| Lives in Philadelphia | `simpleChecks.livesInPhiladelphiaPa` | +| Owns and occupies home | `simpleChecks.ownerOccupant` | +| Delinquent property taxes | `simpleChecks.taxDelinquent` | +| Ten-year property tax abatement | `simpleChecks.tenYearTaxAbatement` | +| Currently has a spouse; spouse birthdate | `people.spouse.exists`, `people.spouse.dateOfBirth` | +| Late spouse was at least 65 | `simpleChecks.lateSpouseWasAtLeast65` | +| Confirmed program enrollments | `people.client.enrollments` | + +Yes/No questions use picklists with **Unknown** so missing answers never imply No. +Income of zero remains zero. Enrollment information must be **Confirmed** before +program selections are prefilled. Confirmed with no programs selected becomes the +form's explicit **None of these** answer; unreviewed enrollment stays unknown. +Only screening answers are sent to BDT. Names, contact information, client notes, +service status, and Salesforce credentials are not included in the prefill. + +## Install in a development org + +In a Salesforce DX project: + +1. Copy `bdtClientHeader/`, `bdtClientIntake/`, `bdtScreening/`, + `bdtScreeningResults/`, and `bdtCrmHost/` into `force-app/main/default/lwc/`. + Copy `builder-frontend/public/integrations/host.js` from this repository to + `force-app/main/default/lwc/bdtCrmHost/bdtCrmHost.js`. +2. Copy `BDT_Screening__c.field-meta.xml` and every field under `client-fields/` + into `force-app/main/default/objects/Contact/fields/`. Copy + `BDT_Screening.permissionset-meta.xml` into `force-app/main/default/permissionsets/`. +3. Copy `BDT_Client_Record.flexipage-meta.xml` into `force-app/main/default/flexipages/`. + Replace its example screener URL with your published screener URL. Copy + `Contact-BDT Client Layout.layout-meta.xml` into `force-app/main/default/layouts/` + and `Community_Client_Services.app-meta.xml` into `force-app/main/default/applications/`. + Copy `Community_Clients.listView-meta.xml` into + `force-app/main/default/objects/Contact/listViews/`. +4. Deploy to a **Developer Edition or sandbox org**. Assign **BDT Screening** to + your test user. It grants Contact read/edit and access to the listed client + intake fields and screening result field, without record creation, deletion, + View All, or Modify All access. Make the app visible to the user's profile and + assign the **BDT Client Layout** Contact layout. +5. In Setup → Trusted URLs, allow **frame-src** for your BDT frontend origin. + Use HTTPS and Lightning Web Security. If the BDT host sets CSP + `frame-ancestors`, it must allow your Salesforce origin. +6. Open **BDT Client Record** in Lightning App Builder, verify the screener URL, + and activate it as the **App Default** for **Community Client Services**. Its main column has + **BDT Client Intake** (`screening`), **BDT Screening**, and **BDT Saved Screening + Results**. Its sidebar uses **BDT Client Intake** (`profile`). The header uses + **BDT Client Header**. Salesforce requires Account on the standard Contact + layout; the custom client page does not display that field. + +The BDT frontend must include this branch's CRM messaging hooks. An older +frontend can render while the integration keeps waiting for results. If a +metadata page change does not appear immediately, open **Edit Page**, check the +components and URL, and click **Save** in Lightning App Builder. + +## Client workflow + +1. Open a client and use **Edit client profile** for contact details and service + coordination. Use **Edit screening intake** for the client's screening answers. +2. Save intake, then click **Screen here** or **Screen in new window**. A newly + started screening reads the latest saved intake. Saving intake does not alter + an already-open screening. +3. Review or edit the prefilled screening answers and results. Click **Save + results to client** to store the latest evaluated snapshot in + `Contact.BDT_Screening__c`. +4. **Saved screening results** shows benefit status cards, the evaluation time, + and expandable checks. It refreshes after saving and remains available when + the client record is reopened. + +Edits made inside the screener do not write intake fields back to Salesforce. +The results field stores only the latest snapshot, replacing previous results. +Use a related Screening object if you need history. Screening results are +preliminary and do not submit an application or establish program approval. +The Philadelphia example includes fictional Philly Cash. + +During Developer Edition validation on 2026-10-06, the connected live BDT backend +returned unknown enrollment checks for explicit empty enrollment lists. The +client mapper and form preserve the explicit None answer; verify this case +against your backend version. The synthetic Jordan scenario records an existing +Homestead enrollment, while Casey illustrates pending intake. + +Salesforce enables the adapter's `serializeMessages` option: initialization and +subsequent messages use JSON text to avoid Lightning Web Security proxy issues +with popup windows. The origin, source, session, and request checks still apply. + +The adapter pins the client record in callbacks and discards results after +navigation to another record. Adapt `clientPrefill.js` to other screener schemas. +Run the mapping checks with: + +```sh +node examples/crm/salesforce/tests/clientPrefill.test.mjs +``` + +Salesforce references: + +- [Record edit forms](https://developer.salesforce.com/docs/platform/lwc/guide/data-edit-record.html) +- [getRecord](https://developer.salesforce.com/docs/platform/lwc/guide/reference-wire-adapters-record.html) +- [updateRecord](https://developer.salesforce.com/docs/platform/lwc/guide/reference-update-record.html) +- [Objects proxied by Lightning Web Security](https://developer.salesforce.com/docs/platform/lightning-components-security/guide/lws-proxies.html) +- [Iframe access with Lightning Web Security](https://developer.salesforce.com/docs/platform/lightning-components-security/guide/lws-iframes.html) +- [Trusted URLs and third-party JavaScript](https://developer.salesforce.com/docs/platform/lwc/guide/js-api-calls) diff --git a/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.css b/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.css new file mode 100644 index 00000000..de6e48b2 --- /dev/null +++ b/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.css @@ -0,0 +1,29 @@ +.client-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + background: #fff; + padding: 1.25rem; + border-radius: 0.5rem; + border: 1px solid #dce5ee; +} +.eyebrow { + color: #425d72; + font-weight: 700; + font-size: 0.8rem; +} +h1 { + color: #16324f; + font-size: 1.65rem; + font-weight: 700; + margin: 0.15rem 0; +} +.service-status { + margin-left: auto; + background: #e7f2ed; + color: #205846; + padding: 0.4rem 0.75rem; + border-radius: 1rem; + font-weight: 600; +} diff --git a/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.html b/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.html new file mode 100644 index 00000000..60c9f826 --- /dev/null +++ b/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.html @@ -0,0 +1,14 @@ + diff --git a/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.js b/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.js new file mode 100644 index 00000000..f52beb28 --- /dev/null +++ b/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.js @@ -0,0 +1,24 @@ +import { LightningElement, api, wire } from "lwc"; +import { getRecord, getFieldValue } from "lightning/uiRecordApi"; +import NAME from "@salesforce/schema/Contact.Name"; +import STATUS from "@salesforce/schema/Contact.BDT_Client_Status__c"; + +export default class BdtClientHeader extends LightningElement { + @api recordId; + @wire(getRecord, { + recordId: "$recordId", + fields: [NAME], + optionalFields: [STATUS], + }) + client; + get name() { + return this.client?.data?.id === this.recordId + ? getFieldValue(this.client.data, NAME) + : "Client"; + } + get status() { + return this.client?.data?.id === this.recordId + ? (getFieldValue(this.client.data, STATUS) ?? "Status not recorded") + : ""; + } +} diff --git a/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.js-meta.xml b/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.js-meta.xml new file mode 100644 index 00000000..eee49172 --- /dev/null +++ b/examples/crm/salesforce/bdtClientHeader/bdtClientHeader.js-meta.xml @@ -0,0 +1,6 @@ + + + 64.0trueBDT Client Header + lightning__RecordPage + Contact + diff --git a/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.css b/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.css new file mode 100644 index 00000000..19820b5c --- /dev/null +++ b/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.css @@ -0,0 +1,31 @@ +.intro { + color: #475569; + margin-bottom: 1rem; + line-height: 1.5; +} +.sections { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 1rem 1.5rem; +} +h3 { + font-size: 0.9rem; + font-weight: 700; + color: #16324f; + border-bottom: 1px solid #dce5ee; + padding-bottom: 0.4rem; + margin-bottom: 0.5rem; +} +.actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; +} +.status { + margin-top: 0.75rem; +} +.field-label { + font-size: 0.75rem; + color: #444; + margin-bottom: 0.2rem; +} diff --git a/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.html b/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.html new file mode 100644 index 00000000..8c588494 --- /dev/null +++ b/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.html @@ -0,0 +1,87 @@ + diff --git a/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.js b/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.js new file mode 100644 index 00000000..3994cd63 --- /dev/null +++ b/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.js @@ -0,0 +1,128 @@ +import { LightningElement, api, wire } from "lwc"; +import { getRecord, getFieldValue } from "lightning/uiRecordApi"; +import CONTACT_ID from "@salesforce/schema/Contact.Id"; +import OWNER_NAME from "@salesforce/schema/Contact.Owner.Name"; + +const PROFILE = [ + { + title: "Contact details", + fields: ["Name", "Phone", "Email", "MailingAddress"], + }, + { + title: "Service coordination", + fields: [ + "BDT_Client_Status__c", + "BDT_Intake_Date__c", + "BDT_Preferred_Contact__c", + "BDT_Household_Size__c", + "BDT_Client_Notes__c", + ], + }, +]; +const SCREENING = [ + { + title: "Household & assistance", + fields: [ + "Birthdate", + "BDT_Philly_Resident__c", + "BDT_Household_Income__c", + "BDT_Wants_Cash__c", + ], + }, + { + title: "Housing & property taxes", + fields: [ + "BDT_Owner_Occupant__c", + "BDT_Tax_Delinquent__c", + "BDT_Tax_Abatement__c", + ], + }, + { + title: "Age & family", + fields: [ + "BDT_Has_Spouse__c", + "BDT_Spouse_Birthdate__c", + "BDT_Late_Spouse_65__c", + ], + }, + { + title: "Current program enrollment", + fields: ["BDT_Enrollment_Review__c", "BDT_Enrolled_Programs__c"], + }, +]; + +export default class BdtClientIntake extends LightningElement { + _recordId; + @api get recordId() { + return this._recordId; + } + set recordId(value) { + if (value !== this._recordId) { + this.editing = false; + this.status = ""; + } + this._recordId = value; + } + @api variant = "screening"; + @wire(getRecord, { + recordId: "$recordId", + fields: [CONTACT_ID], + optionalFields: [OWNER_NAME], + }) + client; + editing = false; + saving = false; + status = ""; + get profile() { + return this.variant === "profile"; + } + get title() { + return this.profile ? "Client profile" : "Screening intake"; + } + get editLabel() { + return this.profile ? "Edit client profile" : "Edit screening intake"; + } + get introduction() { + return this.profile + ? "Contact information and service coordination for benefits navigation." + : "Saved answers prefill the benefit screener. Keep unanswered questions Unknown. Confirm enrollment information even when no programs are selected."; + } + get sections() { + return (this.profile ? PROFILE : SCREENING).map((section) => ({ + ...section, + fields: section.fields.map((apiName) => ({ + apiName, + label: apiName === "Phone" ? "Phone" : null, + variant: apiName === "Phone" ? "label-hidden" : "label-stacked", + })), + })); + } + get staffName() { + return this.client?.data?.id === this.recordId + ? (getFieldValue(this.client.data, OWNER_NAME) ?? "Not recorded") + : "Loading…"; + } + edit() { + this.editing = true; + this.status = ""; + } + cancel() { + this.editing = false; + this.status = ""; + } + submit() { + this.saving = true; + this.status = "Saving client information…"; + } + success() { + this.saving = false; + this.editing = false; + this.status = this.profile + ? "Client profile saved." + : "Intake saved. Start a new screening to use these answers."; + } + error() { + this.saving = false; + this.status = "Could not save. Review the field errors and try again."; + } +} diff --git a/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.js-meta.xml b/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.js-meta.xml new file mode 100644 index 00000000..a3725a30 --- /dev/null +++ b/examples/crm/salesforce/bdtClientIntake/bdtClientIntake.js-meta.xml @@ -0,0 +1,9 @@ + + + 64.0trueBDT Client Intake + lightning__RecordPage + + + Contact + + diff --git a/examples/crm/salesforce/bdtCrmHost/bdtCrmHost.js-meta.xml b/examples/crm/salesforce/bdtCrmHost/bdtCrmHost.js-meta.xml new file mode 100644 index 00000000..bba30894 --- /dev/null +++ b/examples/crm/salesforce/bdtCrmHost/bdtCrmHost.js-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + false + diff --git a/examples/crm/salesforce/bdtScreening/bdtScreening.css b/examples/crm/salesforce/bdtScreening/bdtScreening.css new file mode 100644 index 00000000..0d9ea5cb --- /dev/null +++ b/examples/crm/salesforce/bdtScreening/bdtScreening.css @@ -0,0 +1,5 @@ +iframe { + width: 100%; + height: 700px; + border: 0; +} diff --git a/examples/crm/salesforce/bdtScreening/bdtScreening.html b/examples/crm/salesforce/bdtScreening/bdtScreening.html new file mode 100644 index 00000000..a0cc583f --- /dev/null +++ b/examples/crm/salesforce/bdtScreening/bdtScreening.html @@ -0,0 +1,29 @@ + diff --git a/examples/crm/salesforce/bdtScreening/bdtScreening.js b/examples/crm/salesforce/bdtScreening/bdtScreening.js new file mode 100644 index 00000000..f4ad57d6 --- /dev/null +++ b/examples/crm/salesforce/bdtScreening/bdtScreening.js @@ -0,0 +1,161 @@ +import { LightningElement, api, wire } from "lwc"; +import { getRecord, getFieldValue, updateRecord } from "lightning/uiRecordApi"; +import BIRTHDATE from "@salesforce/schema/Contact.Birthdate"; +import SCREENING from "@salesforce/schema/Contact.BDT_Screening__c"; +import { createCrmIntegration } from "c/bdtCrmHost"; + +import { clientPrefill } from "./clientPrefill"; +import PHILLY_RESIDENT from "@salesforce/schema/Contact.BDT_Philly_Resident__c"; +import WANTS_CASH from "@salesforce/schema/Contact.BDT_Wants_Cash__c"; +import HOUSEHOLD_INCOME from "@salesforce/schema/Contact.BDT_Household_Income__c"; +import OWNER_OCCUPANT from "@salesforce/schema/Contact.BDT_Owner_Occupant__c"; +import TAX_DELINQUENT from "@salesforce/schema/Contact.BDT_Tax_Delinquent__c"; +import TAX_ABATEMENT from "@salesforce/schema/Contact.BDT_Tax_Abatement__c"; +import HAS_SPOUSE from "@salesforce/schema/Contact.BDT_Has_Spouse__c"; +import SPOUSE_BIRTHDATE from "@salesforce/schema/Contact.BDT_Spouse_Birthdate__c"; +import LATE_SPOUSE_65 from "@salesforce/schema/Contact.BDT_Late_Spouse_65__c"; +import ENROLLMENT_REVIEW from "@salesforce/schema/Contact.BDT_Enrollment_Review__c"; +import ENROLLED_PROGRAMS from "@salesforce/schema/Contact.BDT_Enrolled_Programs__c"; + +const OPTIONAL_INTAKE_FIELDS = [ + PHILLY_RESIDENT, + WANTS_CASH, + HOUSEHOLD_INCOME, + OWNER_OCCUPANT, + TAX_DELINQUENT, + TAX_ABATEMENT, + HAS_SPOUSE, + SPOUSE_BIRTHDATE, + LATE_SPOUSE_65, + ENROLLMENT_REVIEW, + ENROLLED_PROGRAMS, +]; + +export default class BdtScreening extends LightningElement { + _recordId; + @api + get recordId() { + return this._recordId; + } + set recordId(value) { + if (value !== this._recordId) { + this.connection?.dispose(); + this.frameUrl = undefined; + this.latest = undefined; + this.status = + "Start a screening with this client’s saved intake answers."; + } + this._recordId = value; + } + @api screenerUrl; + @wire(getRecord, { + recordId: "$recordId", + fields: [BIRTHDATE], + optionalFields: OPTIONAL_INTAKE_FIELDS, + }) + contact; + frameUrl; + latest; + status = "Start a screening with this client’s saved intake answers."; + saving = false; + connection; + popup; + + get cannotSave() { + return ( + !this.latest || this.saving || this.latest.recordId !== this.recordId + ); + } + + start(event) { + this.connection?.dispose(); + this.frameUrl = undefined; + this.latest = undefined; + const recordId = this.recordId; + if (!this.contact?.data || this.contact.data.id !== recordId) { + this.status = + "The client could not be loaded. Check field permissions and try again."; + return; + } + const popupMode = event.currentTarget.dataset.mode === "popup"; + try { + this.connection = createCrmIntegration({ + screenerUrl: this.screenerUrl, + // LWS proxies can prevent structured cloning to a popup window. + serializeMessages: true, + getTargetWindow: () => + popupMode + ? this.popup + : this.template.querySelector("iframe")?.contentWindow, + initialData: clientPrefill( + Object.fromEntries( + [BIRTHDATE, ...OPTIONAL_INTAKE_FIELDS].map((field) => [ + field.fieldApiName, + getFieldValue(this.contact.data, field), + ]), + ), + ), + onResult: (message) => { + if (this.recordId !== recordId) return; + this.latest = { recordId, message }; + this.status = + "Results received. Review them, then save to the client record."; + }, + onError: (message) => { + if (this.recordId !== recordId) return; + this.latest = undefined; + this.status = + message.code === "INITIALIZATION_FAILED" + ? "Could not connect to the screener. Try Screen here or open a new screening window." + : "Evaluation failed. Change an answer to retry."; + }, + }); + this.status = "Waiting for screening results…"; + if (popupMode) { + this.popup = window.open(this.connection.url, "_blank"); + if (!this.popup) { + this.connection.dispose(); + this.status = "Allow popups for Salesforce, then try again."; + } + } else { + this.frameUrl = this.connection.url; + } + } catch { + this.status = + "Configure a valid absolute published screener URL in App Builder."; + } + } + + async save() { + if (this.cannotSave) return; + const { recordId, message } = this.latest; + const snapshot = JSON.stringify({ + screenerId: message.screenerId, + requestId: message.requestId, + evaluatedAt: message.evaluatedAt, + results: message.results, + }); + if (snapshot.length > 131072) { + this.status = + "Results exceed the configured field size; use a related screening object."; + return; + } + this.saving = true; + try { + await updateRecord({ + fields: { Id: recordId, [SCREENING.fieldApiName]: snapshot }, + }); + if (this.recordId === recordId) + this.status = "Screening results saved to the client record."; + } catch { + if (this.recordId === recordId) + this.status = "Could not save. Check field permissions and try again."; + } finally { + this.saving = false; + } + } + + disconnectedCallback() { + this.connection?.dispose(); + } +} diff --git a/examples/crm/salesforce/bdtScreening/bdtScreening.js-meta.xml b/examples/crm/salesforce/bdtScreening/bdtScreening.js-meta.xml new file mode 100644 index 00000000..6858c71a --- /dev/null +++ b/examples/crm/salesforce/bdtScreening/bdtScreening.js-meta.xml @@ -0,0 +1,12 @@ + + + 64.0 + true + lightning__RecordPage + + + + Contact + + + diff --git a/examples/crm/salesforce/bdtScreening/clientPrefill.js b/examples/crm/salesforce/bdtScreening/clientPrefill.js new file mode 100644 index 00000000..0145b570 --- /dev/null +++ b/examples/crm/salesforce/bdtScreening/clientPrefill.js @@ -0,0 +1,45 @@ +const PROGRAMS = [ + "PhlHomesteadExemption", + "PhlOwnerOccupiedPaymentAgreement", + "PhlSeniorCitizenTaxFreeze", +]; + +function yesNo(value) { + return value === "Yes" ? true : value === "No" ? false : null; +} + +// Keys belong to the published Philadelphia example, not the Salesforce schema. +// An explicit None marker is required by the form's checklist_none field. +export function clientPrefill(values) { + const selected = (values.BDT_Enrolled_Programs__c ?? "") + .split(";") + .filter((program) => PROGRAMS.includes(program)); + const enrollments = + values.BDT_Enrollment_Review__c === "Confirmed" + ? selected.length + ? selected + : ["__bdt_none_of_these__"] + : null; + const hasSpouse = yesNo(values.BDT_Has_Spouse__c); + return { + people: { + client: { dateOfBirth: values.Birthdate ?? null, enrollments }, + spouse: { + exists: hasSpouse, + dateOfBirth: + hasSpouse === true ? (values.BDT_Spouse_Birthdate__c ?? null) : null, + }, + }, + custom: { + wantsExtraCash: yesNo(values.BDT_Wants_Cash__c), + householdIncome: values.BDT_Household_Income__c ?? null, + }, + simpleChecks: { + livesInPhiladelphiaPa: yesNo(values.BDT_Philly_Resident__c), + ownerOccupant: yesNo(values.BDT_Owner_Occupant__c), + taxDelinquent: yesNo(values.BDT_Tax_Delinquent__c), + tenYearTaxAbatement: yesNo(values.BDT_Tax_Abatement__c), + lateSpouseWasAtLeast65: yesNo(values.BDT_Late_Spouse_65__c), + }, + }; +} diff --git a/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.css b/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.css new file mode 100644 index 00000000..4a1fae69 --- /dev/null +++ b/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.css @@ -0,0 +1,126 @@ +.saved-results { + padding: 0.5rem 1rem 1rem; +} + +.snapshot-meta { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.25rem 1rem; + color: #444; + font-size: 0.875rem; +} + +.snapshot-note, +.screener-reference { + color: #5c5c5c; +} + +.evaluated-at { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.totals { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.5rem; + padding: 0.875rem 0; + list-style: none; +} + +.totals strong { + font-size: 1.125rem; + margin-right: 0.25rem; +} + +.benefits { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); + gap: 0.75rem; +} + +.benefit { + border: 1px solid #d8dde6; + border-radius: 0.5rem; + padding: 1rem; + min-width: 0; +} + +.benefit-header, +.checks li { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.benefit-header { + flex-wrap: wrap; +} + +.benefit h3 { + font-weight: 600; + font-size: 1rem; +} + +.outcome { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 0.75rem; + font-weight: 600; + font-size: 0.75rem; + flex-shrink: 0; +} + +.outcome_pass { + background: #e6f4ea; + color: #1b5e20; +} + +.outcome_fail { + background: #fce8e6; + color: #8e2424; +} + +.outcome_unknown { + background: #fff3cd; + color: #664d03; +} + +.check-details { + margin-top: 0.75rem; +} + +.check-details summary { + display: list-item; + list-style: disclosure-closed inside; + color: #015ba7; + cursor: pointer; + padding: 0.25rem 0; +} + +.check-details[open] summary { + list-style-type: disclosure-open; +} + +.checks { + margin-top: 0.25rem; + list-style: none; +} + +.checks li { + padding: 0.6rem 0; + border-top: 1px solid #ecebea; +} + +.check-name { + overflow-wrap: anywhere; +} + +.screener-reference { + margin-top: 0.875rem; + font-size: 0.75rem; + overflow-wrap: anywhere; +} diff --git a/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.html b/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.html new file mode 100644 index 00000000..17745c92 --- /dev/null +++ b/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.html @@ -0,0 +1,64 @@ + diff --git a/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.js b/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.js new file mode 100644 index 00000000..ded7857c --- /dev/null +++ b/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.js @@ -0,0 +1,48 @@ +import { LightningElement, api, wire } from "lwc"; +import { getRecord, getFieldValue } from "lightning/uiRecordApi"; +import CONTACT_ID from "@salesforce/schema/Contact.Id"; +import SCREENING from "@salesforce/schema/Contact.BDT_Screening__c"; +import { formatSavedResults } from "./formatResults"; + +export default class BdtScreeningResults extends LightningElement { + @api recordId; + @wire(getRecord, { + recordId: "$recordId", + fields: [CONTACT_ID], + optionalFields: [SCREENING], + }) + contact; + + get view() { + const data = this.contact?.data; + if (!data || data.id !== this.recordId) { + return { + message: this.contact?.error + ? "Could not load saved results. Refresh the page to retry." + : "Loading saved results…", + }; + } + if ( + !Object.prototype.hasOwnProperty.call(data.fields, SCREENING.fieldApiName) + ) { + return { + message: + "Saved results aren’t available. Check access to the BDT Screening field.", + }; + } + const raw = getFieldValue(data, SCREENING); + if (!raw) + return { + message: + "No screening results saved yet. Run a screening, then choose Save results to client.", + }; + try { + return { saved: true, ...formatSavedResults(raw) }; + } catch { + return { + message: + "The saved snapshot could not be displayed. Run a screening and save a new result.", + }; + } + } +} diff --git a/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.js-meta.xml b/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.js-meta.xml new file mode 100644 index 00000000..df2f3f8a --- /dev/null +++ b/examples/crm/salesforce/bdtScreeningResults/bdtScreeningResults.js-meta.xml @@ -0,0 +1,12 @@ + + + 64.0 + true + BDT Saved Screening Results + lightning__RecordPage + + + Contact + + + diff --git a/examples/crm/salesforce/bdtScreeningResults/formatResults.js b/examples/crm/salesforce/bdtScreeningResults/formatResults.js new file mode 100644 index 00000000..895f3873 --- /dev/null +++ b/examples/crm/salesforce/bdtScreeningResults/formatResults.js @@ -0,0 +1,83 @@ +const isObject = (value) => + value !== null && typeof value === "object" && !Array.isArray(value); + +function outcome(value, check = false) { + if (value === "TRUE") + return { + label: check ? "Passed" : "Eligible", + className: "outcome outcome_pass", + }; + if (value === "FALSE") + return { + label: check ? "Not met" : "Ineligible", + className: "outcome outcome_fail", + }; + return { + label: check ? "Unknown" : "Needs information", + className: "outcome outcome_unknown", + }; +} + +function checkName(check) { + const name = check.aliasName || check.name; + if (typeof name !== "string") return "Unnamed check"; + return name + .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[-_]+/g, " ") + .trim(); +} + +export function formatSavedResults(raw) { + const snapshot = JSON.parse(raw); + if (!isObject(snapshot) || !isObject(snapshot.results)) + throw new Error("Invalid saved screening snapshot"); + + const counts = { eligible: 0, ineligible: 0, unknown: 0 }; + const benefits = Object.entries(snapshot.results).map(([id, benefit]) => { + if (!isObject(benefit)) throw new Error("Invalid saved benefit result"); + counts[ + benefit.result === "TRUE" + ? "eligible" + : benefit.result === "FALSE" + ? "ineligible" + : "unknown" + ]++; + const checks = isObject(benefit.check_results) + ? Object.entries(benefit.check_results).map(([checkId, check]) => { + if (!isObject(check)) throw new Error("Invalid saved check result"); + const status = outcome(check.result, true); + return { + id: checkId, + name: checkName(check), + status: status.label, + statusClass: status.className, + }; + }) + : []; + const status = outcome(benefit.result); + return { + id, + name: typeof benefit.name === "string" ? benefit.name : "Unnamed benefit", + status: status.label, + statusClass: status.className, + checks, + hasChecks: checks.length > 0, + checksLabel: `View checks (${checks.length})`, + }; + }); + const timestamp = + typeof snapshot.evaluatedAt === "string" + ? Date.parse(snapshot.evaluatedAt) + : NaN; + return { + benefits, + hasBenefits: benefits.length > 0, + evaluatedAt: Number.isFinite(timestamp) + ? new Date(timestamp).toISOString() + : null, + screenerId: + typeof snapshot.screenerId === "string" ? snapshot.screenerId : "", + ...counts, + }; +} diff --git a/examples/crm/salesforce/client-fields/BDT_Client_Notes__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Client_Notes__c.field-meta.xml new file mode 100644 index 00000000..e168ec3a --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Client_Notes__c.field-meta.xml @@ -0,0 +1 @@ +BDT_Client_Notes__cLongTextArea100004 \ No newline at end of file diff --git a/examples/crm/salesforce/client-fields/BDT_Client_Status__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Client_Status__c.field-meta.xml new file mode 100644 index 00000000..f6c9708e --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Client_Status__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Client_Status__cPicklisttruefalseNew intakefalseScreening in progressfalseApplication supportfalseFollow-up neededfalseServices completefalse diff --git a/examples/crm/salesforce/client-fields/BDT_Enrolled_Programs__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Enrolled_Programs__c.field-meta.xml new file mode 100644 index 00000000..b768b88d --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Enrolled_Programs__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Enrolled_Programs__cMultiselectPicklisttruefalsePhlHomesteadExemptionfalsePhlOwnerOccupiedPaymentAgreementfalsePhlSeniorCitizenTaxFreezefalse3Select all current enrollments. Leave empty if none, and set Enrollment information to Confirmed. diff --git a/examples/crm/salesforce/client-fields/BDT_Enrollment_Review__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Enrollment_Review__c.field-meta.xml new file mode 100644 index 00000000..c4c334b6 --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Enrollment_Review__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Enrollment_Review__cPicklisttruefalseNot reviewedfalseConfirmedfalseChoose Confirmed after reviewing the programs below with the client. Confirmed with no selected programs means None of these; Not reviewed means unknown. diff --git a/examples/crm/salesforce/client-fields/BDT_Has_Spouse__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Has_Spouse__c.field-meta.xml new file mode 100644 index 00000000..c8d57f2b --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Has_Spouse__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Has_Spouse__cPicklisttruefalseUnknownfalseYesfalseNofalseLeave Unknown when this question has not been answered. Choose No when it does not apply. diff --git a/examples/crm/salesforce/client-fields/BDT_Household_Income__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Household_Income__c.field-meta.xml new file mode 100644 index 00000000..7443caa0 --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Household_Income__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Household_Income__cCurrency160Total yearly household income in dollars. Leave blank if unknown; zero means no income. diff --git a/examples/crm/salesforce/client-fields/BDT_Household_Size__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Household_Size__c.field-meta.xml new file mode 100644 index 00000000..5cb8b15f --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Household_Size__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Household_Size__cNumber30Client tracking only; the current demo screener does not use household size. diff --git a/examples/crm/salesforce/client-fields/BDT_Intake_Date__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Intake_Date__c.field-meta.xml new file mode 100644 index 00000000..f65fb7a4 --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Intake_Date__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Intake_Date__cDate diff --git a/examples/crm/salesforce/client-fields/BDT_Late_Spouse_65__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Late_Spouse_65__c.field-meta.xml new file mode 100644 index 00000000..12be016f --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Late_Spouse_65__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Late_Spouse_65__cPicklisttruefalseUnknownfalseYesfalseNofalseLeave Unknown when this question has not been answered. Choose No when it does not apply. diff --git a/examples/crm/salesforce/client-fields/BDT_Owner_Occupant__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Owner_Occupant__c.field-meta.xml new file mode 100644 index 00000000..952b3fc8 --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Owner_Occupant__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Owner_Occupant__cPicklisttruefalseUnknownfalseYesfalseNofalseLeave Unknown when this question has not been answered. Choose No when it does not apply. diff --git a/examples/crm/salesforce/client-fields/BDT_Philly_Resident__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Philly_Resident__c.field-meta.xml new file mode 100644 index 00000000..4519aa7b --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Philly_Resident__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Philly_Resident__cPicklisttruefalseUnknownfalseYesfalseNofalseLeave Unknown when this question has not been answered. Choose No when it does not apply. diff --git a/examples/crm/salesforce/client-fields/BDT_Preferred_Contact__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Preferred_Contact__c.field-meta.xml new file mode 100644 index 00000000..161048cc --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Preferred_Contact__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Preferred_Contact__cPicklisttruefalsePhonefalseEmailfalseText messagefalseMailfalseIn personfalse diff --git a/examples/crm/salesforce/client-fields/BDT_Spouse_Birthdate__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Spouse_Birthdate__c.field-meta.xml new file mode 100644 index 00000000..eef018cc --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Spouse_Birthdate__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Spouse_Birthdate__cDate diff --git a/examples/crm/salesforce/client-fields/BDT_Tax_Abatement__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Tax_Abatement__c.field-meta.xml new file mode 100644 index 00000000..c4b39b9e --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Tax_Abatement__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Tax_Abatement__cPicklisttruefalseUnknownfalseYesfalseNofalseLeave Unknown when this question has not been answered. Choose No when it does not apply. diff --git a/examples/crm/salesforce/client-fields/BDT_Tax_Delinquent__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Tax_Delinquent__c.field-meta.xml new file mode 100644 index 00000000..94274a98 --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Tax_Delinquent__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Tax_Delinquent__cPicklisttruefalseUnknownfalseYesfalseNofalseLeave Unknown when this question has not been answered. Choose No when it does not apply. diff --git a/examples/crm/salesforce/client-fields/BDT_Wants_Cash__c.field-meta.xml b/examples/crm/salesforce/client-fields/BDT_Wants_Cash__c.field-meta.xml new file mode 100644 index 00000000..abcba804 --- /dev/null +++ b/examples/crm/salesforce/client-fields/BDT_Wants_Cash__c.field-meta.xml @@ -0,0 +1,3 @@ + + +BDT_Wants_Cash__cPicklisttruefalseUnknownfalseYesfalseNofalseLeave Unknown when this question has not been answered. Choose No when it does not apply. diff --git a/examples/crm/salesforce/tests/clientPrefill.test.mjs b/examples/crm/salesforce/tests/clientPrefill.test.mjs new file mode 100644 index 00000000..e4758e37 --- /dev/null +++ b/examples/crm/salesforce/tests/clientPrefill.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const source = await readFile( + new URL("../bdtScreening/clientPrefill.js", import.meta.url), + "utf8", +); +const { clientPrefill } = await import( + `data:text/javascript;base64,${Buffer.from(source).toString("base64")}` +); + +test("unanswered intake stays unknown rather than becoming No or no enrollments", () => { + const data = clientPrefill({ BDT_Philly_Resident__c: "Unknown" }); + assert.equal(data.simpleChecks.livesInPhiladelphiaPa, null); + assert.equal(data.custom.householdIncome, null); + assert.equal(data.people.client.enrollments, null); + assert.equal(data.people.spouse.exists, null); +}); + +test("explicit answers preserve zero income and the form's explicit None marker", () => { + const data = clientPrefill({ + BDT_Philly_Resident__c: "Yes", + BDT_Wants_Cash__c: "No", + BDT_Household_Income__c: 0, + BDT_Enrollment_Review__c: "Confirmed", + BDT_Has_Spouse__c: "No", + BDT_Spouse_Birthdate__c: "1950-01-01", + }); + assert.equal(data.simpleChecks.livesInPhiladelphiaPa, true); + assert.equal(data.custom.wantsExtraCash, false); + assert.equal(data.custom.householdIncome, 0); + assert.deepEqual(data.people.client.enrollments, ["__bdt_none_of_these__"]); + assert.deepEqual(data.people.spouse, { exists: false, dateOfBirth: null }); +}); + +test("confirmed program selections and spouse dates map to the published schema", () => { + const data = clientPrefill({ + Birthdate: "1980-02-01", + BDT_Has_Spouse__c: "Yes", + BDT_Spouse_Birthdate__c: "1955-03-01", + BDT_Enrollment_Review__c: "Confirmed", + BDT_Enrolled_Programs__c: "PhlHomesteadExemption;PhlSeniorCitizenTaxFreeze", + BDT_Owner_Occupant__c: "Yes", + BDT_Tax_Delinquent__c: "Yes", + BDT_Tax_Abatement__c: "No", + BDT_Late_Spouse_65__c: "No", + Email: "never-sent@example.org", + BDT_Client_Notes__c: "Private client notes", + }); + assert.deepEqual(data.people.client, { + dateOfBirth: "1980-02-01", + enrollments: ["PhlHomesteadExemption", "PhlSeniorCitizenTaxFreeze"], + }); + assert.deepEqual(data.people.spouse, { + exists: true, + dateOfBirth: "1955-03-01", + }); + assert.deepEqual(data.simpleChecks, { + livesInPhiladelphiaPa: null, + ownerOccupant: true, + taxDelinquent: true, + tenYearTaxAbatement: false, + lateSpouseWasAtLeast65: false, + }); + assert.equal(JSON.stringify(data).includes("Private client notes"), false); + assert.equal(JSON.stringify(data).includes("never-sent@example.org"), false); +}); + +test("unconfirmed enrollment selections remain unknown", () => { + const data = clientPrefill({ + BDT_Enrollment_Review__c: "Not reviewed", + BDT_Enrolled_Programs__c: "PhlHomesteadExemption", + }); + assert.equal(data.people.client.enrollments, null); +}); From 850c42a168b83574fce75850d8b3963b8808ad7b Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Fri, 9 Oct 2026 21:48:08 -0400 Subject: [PATCH 02/10] Start the CRM demo server only for the CRM e2e spec The demo server was a global Playwright webServer, so every project, including the emulator-backed chromium suite, needed port 4174 free. The CRM spec now launches it on a free port itself. Co-Authored-By: Claude Opus 5.5 --- docs/src/content/docs/dev/crm-integrations.md | 6 ++-- e2e/playwright.config.ts | 7 ---- e2e/tests/crmIntegration.spec.ts | 35 ++++++++++++++++++- examples/crm/browser/serve.mjs | 11 ++++-- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/docs/src/content/docs/dev/crm-integrations.md b/docs/src/content/docs/dev/crm-integrations.md index 4970a89f..34ca9f79 100644 --- a/docs/src/content/docs/dev/crm-integrations.md +++ b/docs/src/content/docs/dev/crm-integrations.md @@ -222,9 +222,9 @@ UI and storage; there is no provider registry or server-side CRM plugin runtime. With the frontend dev server running and its example environment configured, run `npx playwright test --project=crm-integrations` from `e2e/`. These Chromium tests intercept the published APIs and verify prefill, edited results, explicit -saving, failure recovery, and standalone behavior. Playwright automatically starts -the browser example server on port 4174, or reuses an already running server -locally. They require no emulators or CRM account. Frontend unit tests also cover +saving, failure recovery, and standalone behavior. The spec starts its own +browser example server on a free port, so it doesn't conflict with a demo server +you already have running. They require no emulators or CRM account. Frontend unit tests also cover origin/source validation, correlation, one-time initialization, outdated responses, and listener cleanup. diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 177198f9..51cf952b 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -30,13 +30,6 @@ export default defineConfig({ screenshot: "only-on-failure", }, - // The CRM host example lives outside frontend deployment assets. - webServer: { - command: 'node ../examples/crm/browser/serve.mjs', - url: 'http://127.0.0.1:4174/demo.html', - reuseExistingServer: !process.env.CI, - }, - /* Configure projects for major browsers */ projects: [ { diff --git a/e2e/tests/crmIntegration.spec.ts b/e2e/tests/crmIntegration.spec.ts index 1bab4557..7a5ffc89 100644 --- a/e2e/tests/crmIntegration.spec.ts +++ b/e2e/tests/crmIntegration.spec.ts @@ -1,6 +1,39 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import path from "node:path"; import { expect, test } from "@playwright/test"; -const demoUrl = "http://127.0.0.1:4174/demo.html"; +let demoServer: ChildProcess | undefined; +let demoUrl: string; + +// Only this spec needs the CRM host example, so it starts its own server on a +// free port instead of making every Playwright project reserve one. +test.beforeAll(async () => { + const server = spawn( + process.execPath, + [path.resolve(__dirname, "../../examples/crm/browser/serve.mjs")], + { + env: { ...process.env, PORT: "0" }, + stdio: ["ignore", "pipe", "inherit"], + }, + ); + demoServer = server; + demoUrl = await new Promise((resolve, reject) => { + let output = ""; + server.stdout!.on("data", (chunk) => { + output += chunk; + const url = output.match(/http:\/\/127\.0\.0\.1:\d+\/demo\.html/); + if (url) resolve(url[0]); + }); + server.once("error", reject); + server.once("exit", (code) => + reject(new Error(`CRM example server exited with code ${code}`)), + ); + }); +}); + +test.afterAll(() => { + demoServer?.kill(); +}); // Public browser integration tests use intercepted API responses, not Firebase. test.beforeEach(async ({ context }) => { diff --git a/examples/crm/browser/serve.mjs b/examples/crm/browser/serve.mjs index ff8e73d4..c0233b78 100644 --- a/examples/crm/browser/serve.mjs +++ b/examples/crm/browser/serve.mjs @@ -22,7 +22,9 @@ const assets = new Map([ ], ]); -createServer(async (request, response) => { +// PORT=0 picks a free port; tests read the printed URL. +const port = Number(process.env.PORT ?? 4174); +const server = createServer(async (request, response) => { if (request.method !== "GET" && request.method !== "HEAD") { response.writeHead(405, { Allow: "GET, HEAD" }).end(); return; @@ -43,6 +45,9 @@ createServer(async (request, response) => { console.error("Could not serve CRM example asset", error); response.writeHead(500).end(); } -}).listen(4174, "127.0.0.1", () => { - console.log("CRM browser example: http://127.0.0.1:4174/demo.html"); +}); +server.listen(port, "127.0.0.1", () => { + console.log( + `CRM browser example: http://127.0.0.1:${server.address().port}/demo.html`, + ); }); From 0168c6c3eedac94f9430fe08bb738063fd866569 Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Fri, 9 Oct 2026 21:48:27 -0400 Subject: [PATCH 03/10] Accept published screener URLs under a base path The host adapter required the path to be exactly /screener/{id}, so a frontend deployed under a prefix such as /bdt was rejected as invalid. Co-Authored-By: Claude Opus 5.5 --- builder-frontend/public/integrations/host.js | 3 ++- .../src/integrations/screenerBridge.test.ts | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/builder-frontend/public/integrations/host.js b/builder-frontend/public/integrations/host.js index f1ffd73c..60a4a6dc 100644 --- a/builder-frontend/public/integrations/host.js +++ b/builder-frontend/public/integrations/host.js @@ -31,7 +31,8 @@ export function createCrmIntegration({ hostWindow = window, }) { const url = new URL(screenerUrl); - const match = url.pathname.match(/^\/screener\/([^/]+)\/?$/); + // Allow a base path or proxy prefix before /screener/{id}. + const match = url.pathname.match(/\/screener\/([^/]+)\/?$/); if (!match || !["http:", "https:"].includes(url.protocol)) { throw new Error("Expected an absolute published BDT screener URL"); } diff --git a/builder-frontend/src/integrations/screenerBridge.test.ts b/builder-frontend/src/integrations/screenerBridge.test.ts index 4295cd5d..d693efd1 100644 --- a/builder-frontend/src/integrations/screenerBridge.test.ts +++ b/builder-frontend/src/integrations/screenerBridge.test.ts @@ -166,6 +166,28 @@ describe("published screener CRM bridge", () => { }); describe("CRM host adapter", () => { + it("accepts screener URLs under a base path and rejects other paths", () => { + const options = { + getTargetWindow: () => undefined, + initialData: {}, + onResult: vi.fn(), + hostWindow: setup().host as any, + }; + const host = createCrmIntegration({ + ...options, + screenerUrl: "https://org.example/bdt/screener/published-id", + }); + expect(new URL(host.url).pathname).toBe("/bdt/screener/published-id"); + host.dispose(); + for (const screenerUrl of [ + "https://org.example/bdt/published-id", + "https://org.example/screener/published-id/extra", + "ftp://org.example/screener/published-id", + ]) + expect(() => createCrmIntegration({ ...options, screenerUrl })).toThrow( + "Expected an absolute published BDT screener URL", + ); + }); it("reports a blocked initialization and allows a later retry", () => { const s = setup(true); const onError = vi.fn(); From a3fff5115c1b5751a924e0357f550f1549dfed37 Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Fri, 9 Oct 2026 21:49:26 -0400 Subject: [PATCH 04/10] Share the unsafe object key check The CRM bridge and array field normalization each kept their own list of prototype keys. The host adapter keeps its own ID helper on purpose, since CRMs copy that file as a standalone module. Co-Authored-By: Claude Opus 5.5 --- builder-frontend/public/integrations/host.js | 2 ++ builder-frontend/src/integrations/screenerBridge.ts | 5 ++--- builder-frontend/src/utils/arrayFieldData.ts | 3 ++- builder-frontend/src/utils/unsafeObjectKeys.ts | 5 +++++ 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 builder-frontend/src/utils/unsafeObjectKeys.ts diff --git a/builder-frontend/public/integrations/host.js b/builder-frontend/public/integrations/host.js index 60a4a6dc..6bde5731 100644 --- a/builder-frontend/public/integrations/host.js +++ b/builder-frontend/public/integrations/host.js @@ -1,3 +1,5 @@ +// CRMs copy this file as-is, so it stays self-contained instead of importing +// the app's helpers. function randomId() { return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0"), diff --git a/builder-frontend/src/integrations/screenerBridge.ts b/builder-frontend/src/integrations/screenerBridge.ts index 2fef5a4f..be907952 100644 --- a/builder-frontend/src/integrations/screenerBridge.ts +++ b/builder-frontend/src/integrations/screenerBridge.ts @@ -1,4 +1,5 @@ import type { ScreenerResult } from "@/types"; +import { isUnsafeObjectKey } from "@/utils/unsafeObjectKeys"; const CHANNEL = "bdt.crm"; const VERSION = 1; @@ -14,9 +15,7 @@ function isJsonData(value: unknown, depth = 0): boolean { if (!value || typeof value !== "object") return false; if (Object.getPrototypeOf(value) !== Object.prototype) return false; return Object.entries(value).every( - ([key, item]) => - !["__proto__", "prototype", "constructor"].includes(key) && - isJsonData(item, depth + 1), + ([key, item]) => !isUnsafeObjectKey(key) && isJsonData(item, depth + 1), ); } diff --git a/builder-frontend/src/utils/arrayFieldData.ts b/builder-frontend/src/utils/arrayFieldData.ts index e2629a2e..19a9ec5d 100644 --- a/builder-frontend/src/utils/arrayFieldData.ts +++ b/builder-frontend/src/utils/arrayFieldData.ts @@ -2,6 +2,7 @@ import { CHECKLIST_WITH_NONE_TYPE, NONE_OF_THESE_VALUE, } from '@/components/screenerEditor/formJsExtensions/customFormFields/ChecklistWithNone'; +import { isUnsafeObjectKey } from '@/utils/unsafeObjectKeys'; interface FormComponent { type: string; @@ -32,7 +33,7 @@ export function normalizeArrayFieldData( if (target === null || typeof target !== 'object') return; const [segment, ...rest] = segments; - if (['__proto__', 'prototype', 'constructor'].includes(segment)) return; + if (isUnsafeObjectKey(segment)) return; if (rest.length > 0) { // A missing parent means the field's section wasn't submitted (e.g. it // is hidden). Creating it would add people or list items to the data. diff --git a/builder-frontend/src/utils/unsafeObjectKeys.ts b/builder-frontend/src/utils/unsafeObjectKeys.ts new file mode 100644 index 00000000..95aee254 --- /dev/null +++ b/builder-frontend/src/utils/unsafeObjectKeys.ts @@ -0,0 +1,5 @@ +// Property names that can reach an object's prototype when copied or assigned. +const UNSAFE_OBJECT_KEYS = ['__proto__', 'prototype', 'constructor']; + +export const isUnsafeObjectKey = (key: string) => + UNSAFE_OBJECT_KEYS.includes(key); From a044d463d7b0bd50831a9dce06c316c556d2887e Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Fri, 9 Oct 2026 21:51:34 -0400 Subject: [PATCH 05/10] Keep unknown CRM answers null in connected screenings form-js imports a null or omitted answer as its empty value, false for checkboxes and '' for text fields. The connected screener then evaluated and reported those as real answers, so a CRM could save an explicit No. Those answers now stay null until the user changes the field. Co-Authored-By: Claude Opus 5.5 --- .../components/screener/FormRenderer.test.tsx | 71 +++++++++++++++++++ .../src/components/screener/FormRenderer.tsx | 19 +++-- .../src/utils/unknownAnswers.test.ts | 57 +++++++++++++++ builder-frontend/src/utils/unknownAnswers.ts | 50 +++++++++++++ docs/src/content/docs/dev/crm-integrations.md | 4 ++ 5 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 builder-frontend/src/components/screener/FormRenderer.test.tsx create mode 100644 builder-frontend/src/utils/unknownAnswers.test.ts create mode 100644 builder-frontend/src/utils/unknownAnswers.ts diff --git a/builder-frontend/src/components/screener/FormRenderer.test.tsx b/builder-frontend/src/components/screener/FormRenderer.test.tsx new file mode 100644 index 00000000..c56d6132 --- /dev/null +++ b/builder-frontend/src/components/screener/FormRenderer.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { render } from "solid-js/web"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import FormRenderer from "./FormRenderer"; + +const schema = { + type: "default", + id: "Form_test", + schemaVersion: 18, + components: [ + { + type: "checkbox", + id: "cash", + key: "custom.wantsExtraCash", + label: "Interested in cash assistance", + }, + { type: "textfield", id: "city", key: "custom.city", label: "City" }, + { + type: "checkbox", + id: "resident", + key: "simpleChecks.livesInPhiladelphiaPa", + label: "Philadelphia resident", + }, + ], +}; + +describe("FormRenderer host prefill", () => { + let dispose: (() => void) | undefined; + afterEach(() => { + dispose?.(); + document.body.innerHTML = ""; + }); + + it("evaluates unknown host answers as null until the user answers them", async () => { + const submitForm = vi.fn(); + const container = document.body.appendChild(document.createElement("div")); + dispose = render( + () => ( + ({ + custom: { wantsExtraCash: null }, + simpleChecks: { livesInPhiladelphiaPa: false }, + })} + hiddenQuestionPaths={() => []} + submitForm={submitForm} + evaluateInitialData + /> + ), + container, + ); + await vi.waitFor(() => expect(submitForm).toHaveBeenCalledOnce()); + expect(submitForm).toHaveBeenLastCalledWith({ + custom: { wantsExtraCash: null, city: null }, + simpleChecks: { livesInPhiladelphiaPa: false }, + }); + + const cash = container.querySelector( + 'input[type="checkbox"]', + )!; + cash.click(); + cash.click(); + await vi.waitFor(() => expect(submitForm).toHaveBeenCalledTimes(2), { + timeout: 3000, + }); + expect(submitForm).toHaveBeenLastCalledWith({ + custom: { wantsExtraCash: false, city: null }, + simpleChecks: { livesInPhiladelphiaPa: false }, + }); + }); +}); diff --git a/builder-frontend/src/components/screener/FormRenderer.tsx b/builder-frontend/src/components/screener/FormRenderer.tsx index b4a1fef2..0eb1c528 100644 --- a/builder-frontend/src/components/screener/FormRenderer.tsx +++ b/builder-frontend/src/components/screener/FormRenderer.tsx @@ -9,6 +9,11 @@ import { State } from "@bpmn-io/form-js-viewer/dist/types/Form"; import CustomFormFieldsModule from "../screenerEditor/formJsExtensions/customFormFields"; import { hideQuestions } from "@/utils/questionVotes"; import { normalizeArrayFieldData } from "@/utils/arrayFieldData"; +import { + findFilledUnknownPaths, + keepUnansweredPaths, + restoreUnknownAnswers, +} from "@/utils/unknownAnswers"; import "@bpmn-io/form-js/dist/assets/form-js.css"; @@ -17,20 +22,24 @@ function FormRenderer({ formData, hiddenQuestionPaths, submitForm, - onDataChange, evaluateInitialData = false, + onDataChange, }: { schema: { [key: string]: any }; formData: Accessor; hiddenQuestionPaths: Accessor; submitForm: (data: any) => void; - onDataChange?: () => void; + /** Evaluate host prefill on load, keeping its unknown answers null until answered. */ evaluateInitialData?: boolean; + onDataChange?: () => void; }) { let container: Element | null = null; let form: Form | undefined; let currentData: any = {}; let importing = false; + let unknownPaths: string[][] = []; + const evaluationData = (data: any) => + normalizeArrayFieldData(schema, restoreUnknownAnswers(data, unknownPaths)); const importVisibleSchema = async () => { if (!form) return; @@ -57,7 +66,7 @@ function FormRenderer({ currentData = cloneDeep(formData()); const debouncedSubmit = debounce((data) => { - submitForm(normalizeArrayFieldData(schema, data)); + submitForm(evaluationData(data)); }, 1000); form @@ -65,12 +74,14 @@ function FormRenderer({ .then(() => { currentData = cloneDeep(form?._getState().data || formData()); if (evaluateInitialData) { - submitForm(normalizeArrayFieldData(schema, currentData)); + unknownPaths = findFilledUnknownPaths(formData(), currentData); + submitForm(evaluationData(currentData)); } form?.on("changed", (event: State) => { const dataChanged = !isEqual(currentData, event.data); currentData = cloneDeep(event.data); if (importing || !dataChanged) return; + unknownPaths = keepUnansweredPaths(unknownPaths, event.data); onDataChange?.(); debouncedSubmit(event.data); }); diff --git a/builder-frontend/src/utils/unknownAnswers.test.ts b/builder-frontend/src/utils/unknownAnswers.test.ts new file mode 100644 index 00000000..4c470038 --- /dev/null +++ b/builder-frontend/src/utils/unknownAnswers.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { + findFilledUnknownPaths, + keepUnansweredPaths, + restoreUnknownAnswers, +} from './unknownAnswers'; + +describe('unknown prefill answers', () => { + const prefill = { + custom: { wantsExtraCash: null, householdIncome: 0 }, + simpleChecks: { livesInPhiladelphiaPa: false }, + people: [{ name: 'Alex' }], + }; + // What form-js holds after importing the prefill. + const formData = { + custom: { wantsExtraCash: false, householdIncome: 0, city: '' }, + simpleChecks: { livesInPhiladelphiaPa: false }, + people: [{ name: 'Alex', note: '' }], + }; + + it('finds empty values form-js filled in for null or omitted answers', () => { + expect(findFilledUnknownPaths(prefill, formData)).toEqual([ + ['custom', 'wantsExtraCash'], + ['custom', 'city'], + ['people', '0', 'note'], + ]); + }); + + it('sends unanswered unknowns as null without mutating form data', () => { + const paths = findFilledUnknownPaths(prefill, formData); + expect(restoreUnknownAnswers(formData, paths)).toEqual({ + custom: { wantsExtraCash: null, householdIncome: 0, city: null }, + simpleChecks: { livesInPhiladelphiaPa: false }, + people: [{ name: 'Alex', note: null }], + }); + expect(formData.custom.wantsExtraCash).toBe(false); + }); + + it('keeps an answer once the user changes it, even back to the empty value', () => { + let paths = findFilledUnknownPaths(prefill, formData); + const checked = structuredClone(formData); + checked.custom.wantsExtraCash = true; + paths = keepUnansweredPaths(paths, checked); + checked.custom.wantsExtraCash = false; + paths = keepUnansweredPaths(paths, checked); + expect(restoreUnknownAnswers(checked, paths).custom).toEqual({ + wantsExtraCash: false, + householdIncome: 0, + city: null, + }); + }); + + it('does not add paths that are missing from the data', () => { + expect(restoreUnknownAnswers({}, [['custom', 'city']])).toEqual({}); + }); +}); diff --git a/builder-frontend/src/utils/unknownAnswers.ts b/builder-frontend/src/utils/unknownAnswers.ts new file mode 100644 index 00000000..3df63456 --- /dev/null +++ b/builder-frontend/src/utils/unknownAnswers.ts @@ -0,0 +1,50 @@ +type Path = string[]; + +// form-js's empty values for checkboxes (false) and text fields (''). +const isFilledEmptyValue = (value: unknown) => value === false || value === ''; + +const valueAt = (data: unknown, path: Path) => + path.reduce( + (value, segment) => (value == null ? undefined : value[segment]), + data, + ); + +/** + * form-js replaces null or omitted prefill answers with its empty values, so an + * unknown CRM answer would be evaluated as "No" or blank. Find those paths. + */ +export function findFilledUnknownPaths( + prefill: unknown, + formData: unknown, + path: Path = [], +): Path[] { + if (isFilledEmptyValue(formData)) { + return valueAt(prefill, path) == null ? [path] : []; + } + if (formData === null || typeof formData !== 'object') return []; + return Object.entries(formData).flatMap(([key, value]) => + findFilledUnknownPaths(prefill, value, [...path, key]), + ); +} + +/** Drop paths the user has answered; an answer stays answered once changed. */ +export function keepUnansweredPaths(paths: Path[], formData: unknown): Path[] { + return paths.filter((path) => isFilledEmptyValue(valueAt(formData, path))); +} + +/** Send still-unanswered unknown answers as null instead of form-js's empty values. */ +export function restoreUnknownAnswers(data: T, paths: Path[]): T { + const result = structuredClone(data); + for (const path of paths) { + const parent = valueAt(result, path.slice(0, -1)); + const key = path[path.length - 1]; + if ( + parent && + typeof parent === 'object' && + isFilledEmptyValue(parent[key]) + ) { + parent[key] = null; + } + } + return result; +} diff --git a/docs/src/content/docs/dev/crm-integrations.md b/docs/src/content/docs/dev/crm-integrations.md index 34ca9f79..6efb4543 100644 --- a/docs/src/content/docs/dev/crm-integrations.md +++ b/docs/src/content/docs/dev/crm-integrations.md @@ -165,6 +165,10 @@ another client's screening, create a fresh connection and navigate the frame or window again. Without a valid origin and parent/opener, it runs as an ordinary standalone screener and sends no integration messages. +A checkbox or text field can't display an unknown answer, so it appears +unchecked or empty. The connected screener still evaluates and returns a `null` +or omitted prefill answer as `null` until the user changes that field. + `result` is sent after initial evaluation and after evaluated edits (normally debounced by one second). Multiple results per session are expected. Older in-flight responses are discarded once a newer evaluation has started. A From ea5167069e333fc00bdba039709e1f054029c303 Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Fri, 9 Oct 2026 21:55:02 -0400 Subject: [PATCH 06/10] Stop CRM screenings from waiting forever for a host The screener sent ready once and waited indefinitely, so a reload after the host disposed its connection, a host that started listening late, or a redirect to another origin left staff on the waiting message. The screener now repeats ready until initialized and, after 10 seconds, shows the form unconnected with a notice. The host adapter ignores repeated ready messages for an initialized session and reports CONNECTION_TIMEOUT and ORIGIN_MISMATCH through onError. Co-Authored-By: Claude Opus 5.5 --- builder-frontend/public/integrations/host.js | 35 +++- .../src/components/screener/Screener.test.tsx | 19 ++- .../src/components/screener/Screener.tsx | 23 ++- .../src/integrations/screenerBridge.test.ts | 153 +++++++++++++++++- .../src/integrations/screenerBridge.ts | 36 ++++- docs/src/content/docs/dev/crm-integrations.md | 18 ++- examples/crm/browser/demo.js | 10 +- .../salesforce/bdtScreening/bdtScreening.js | 14 +- 8 files changed, 279 insertions(+), 29 deletions(-) diff --git a/builder-frontend/public/integrations/host.js b/builder-frontend/public/integrations/host.js index 6bde5731..f688400d 100644 --- a/builder-frontend/public/integrations/host.js +++ b/builder-frontend/public/integrations/host.js @@ -18,8 +18,14 @@ function randomId() { * onResult: (message: any) => void, * onError?: (message: any) => void, * onInitialized?: () => void, + * connectTimeoutMs?: number, * hostWindow?: Window * }} options + * + * onError receives `{ code }`: INITIALIZATION_FAILED, CONNECTION_TIMEOUT (no + * screener initialized within connectTimeoutMs), ORIGIN_MISMATCH (the screener + * answered from another origin, such as after a redirect; includes `origin`), + * or EVALUATION_FAILED. */ export function createCrmIntegration({ screenerUrl, @@ -30,6 +36,7 @@ export function createCrmIntegration({ onResult, onError = () => {}, onInitialized = () => {}, + connectTimeoutMs = 15_000, hostWindow = window, }) { const url = new URL(screenerUrl); @@ -54,10 +61,14 @@ export function createCrmIntegration({ url.searchParams.set("integrationLaunch", randomId()); let sessionId; let initialized = false; + let failedSessionId; + let reportedOriginMismatch = false; + const connectTimer = setTimeout(() => { + if (!initialized) onError({ code: "CONNECTION_TIMEOUT" }); + }, connectTimeoutMs); const receive = (event) => { const target = getTargetWindow(); - if (!target || event.origin !== url.origin || event.source !== target) - return; + if (!target || event.source !== target) return; let message = event.data; if (typeof message === "string") { try { @@ -75,9 +86,21 @@ export function createCrmIntegration({ !message.sessionId ) return; + if (event.origin !== url.origin) { + // Never send prefill to an unexpected origin, but say why nothing happens. + if (message.type === "ready" && !reportedOriginMismatch) { + reportedOriginMismatch = true; + onError({ code: "ORIGIN_MISMATCH", origin: event.origin }); + } + return; + } if (message.type === "ready") { - sessionId = message.sessionId; - initialized = false; + // The screener repeats ready until initialized; a new session is a reload. + if (message.sessionId === sessionId && initialized) return; + if (message.sessionId !== sessionId) { + sessionId = message.sessionId; + initialized = false; + } const initialization = { channel: "bdt.crm", version: 1, @@ -93,6 +116,8 @@ export function createCrmIntegration({ url.origin, ); } catch (error) { + if (failedSessionId === sessionId) return; + failedSessionId = sessionId; console.warn("BDT initialization could not be sent", error); onError({ code: "INITIALIZATION_FAILED" }); } @@ -102,6 +127,7 @@ export function createCrmIntegration({ return; if (message.type === "initialized") { initialized = true; + clearTimeout(connectTimer); onInitialized(); } else if ( initialized && @@ -127,6 +153,7 @@ export function createCrmIntegration({ return { url: url.href, dispose() { + clearTimeout(connectTimer); hostWindow.removeEventListener("message", receive); }, }; diff --git a/builder-frontend/src/components/screener/Screener.test.tsx b/builder-frontend/src/components/screener/Screener.test.tsx index adbe5614..715f51ea 100644 --- a/builder-frontend/src/components/screener/Screener.test.tsx +++ b/builder-frontend/src/components/screener/Screener.test.tsx @@ -11,6 +11,7 @@ const integration = vi.hoisted(() => ({ initialize: undefined as | undefined | ((data: Record) => void), + timeout: undefined as undefined | (() => void), start: vi.fn(), dispose: vi.fn(), result: vi.fn(), @@ -30,9 +31,10 @@ vi.mock("@/api/publishedScreener", () => ({ vi.mock("@/integrations/screenerBridge", () => ({ createScreeningBridge: ( _: string, - initialize: typeof integration.initialize, + callbacks: { initialize: () => void; onTimeout: () => void }, ) => { - integration.initialize = initialize; + integration.initialize = callbacks.initialize; + integration.timeout = callbacks.onTimeout; return integration.enabled ? integration : undefined; }, })); @@ -130,6 +132,19 @@ describe("published screener integration lifecycle", () => { expect(integration.result).not.toHaveBeenCalled(); }); + it("offers the form unconnected when no host initializes it", async () => { + vi.mocked(evaluatePublishedScreener).mockResolvedValue(result("TRUE")); + dispose = render(() => , container); + await vi.waitFor(() => expect(integration.start).toHaveBeenCalledOnce()); + integration.timeout!(); + await vi.waitFor(() => + expect(container.querySelector("button")).not.toBeNull(), + ); + expect(container.textContent).not.toContain("Waiting for information"); + expect(container.textContent).toContain("couldn’t connect"); + expect(evaluatePublishedScreener).not.toHaveBeenCalled(); + }); + it("keeps the standalone form available without waiting or initial evaluation", async () => { integration.enabled = false; vi.mocked(evaluatePublishedScreener).mockResolvedValue(result("TRUE")); diff --git a/builder-frontend/src/components/screener/Screener.tsx b/builder-frontend/src/components/screener/Screener.tsx index 17dfc1f4..acbe0fb7 100644 --- a/builder-frontend/src/components/screener/Screener.tsx +++ b/builder-frontend/src/components/screener/Screener.tsx @@ -35,9 +35,17 @@ export default function Screener() { ); const [screenerResult, setScreenerResult] = createSignal(); const [formData, setFormData] = createSignal({}); - const bridge = createScreeningBridge(params.publishedScreenerId, (data) => { - setFormData(data); - setIntegrationReady(true); + const [integrationFailed, setIntegrationFailed] = createSignal(false); + const bridge = createScreeningBridge(params.publishedScreenerId, { + initialize: (data) => { + setFormData(data); + setIntegrationReady(true); + }, + // Let the user screen without the host rather than wait forever. + onTimeout: () => { + setIntegrationFailed(true); + setIntegrationReady(true); + }, }); const [integrationReady, setIntegrationReady] = createSignal(!bridge); let evaluationSequence = 0; @@ -92,6 +100,13 @@ export default function Screener() { Waiting for information from the connected application…

    + + + {screener() && integrationReady() && (
    @@ -100,7 +115,7 @@ export default function Screener() { formData={formData} hiddenQuestionPaths={hiddenQuestionPaths} submitForm={submitForm} - evaluateInitialData={!!bridge} + evaluateInitialData={!!bridge && !integrationFailed()} onDataChange={() => { evaluationSequence++; setResultsStale(true); diff --git a/builder-frontend/src/integrations/screenerBridge.test.ts b/builder-frontend/src/integrations/screenerBridge.test.ts index d693efd1..0d50c15a 100644 --- a/builder-frontend/src/integrations/screenerBridge.test.ts +++ b/builder-frontend/src/integrations/screenerBridge.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; -import { createScreeningBridge } from "./screenerBridge"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CONNECT_TIMEOUT_MS, createScreeningBridge } from "./screenerBridge"; import { createCrmIntegration } from "../../public/integrations/host.js"; const results = { @@ -24,9 +24,10 @@ function setup(popup = false) { }); if (popup) child.parent = child as any; const initialize = vi.fn(); + const onTimeout = vi.fn(); const bridge = createScreeningBridge( "published-id", - initialize, + { initialize, onTimeout }, child as any, )!; const dispatch = ( @@ -51,6 +52,7 @@ function setup(popup = false) { return { bridge, initialize, + onTimeout, child, host, init, @@ -69,7 +71,42 @@ function setup(popup = false) { }; } +beforeEach(() => { + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + describe("published screener CRM bridge", () => { + it("repeats ready until a host initializes it", () => { + const s = setup(); + s.bridge.start(); + vi.advanceTimersByTime(1000); + expect(s.hostPost).toHaveBeenCalledTimes(3); + s.send(); + expect(s.hostPost).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "initialized" }), + "https://crm.example", + ); + vi.advanceTimersByTime(CONNECT_TIMEOUT_MS); + expect(s.hostPost).toHaveBeenCalledTimes(4); + expect(s.onTimeout).not.toHaveBeenCalled(); + s.bridge.dispose(); + }); + + it("stops and reports a timeout when no host initializes it", () => { + const s = setup(true); + s.bridge.start(); + vi.advanceTimersByTime(CONNECT_TIMEOUT_MS); + expect(s.onTimeout).toHaveBeenCalledOnce(); + const readyCount = s.hostPost.mock.calls.length; + vi.advanceTimersByTime(CONNECT_TIMEOUT_MS); + s.send(); + expect(s.hostPost).toHaveBeenCalledTimes(readyCount); + expect(s.initialize).not.toHaveBeenCalled(); + }); + it.each([false, true])( "prefills once and correlates results for popup=%s", (popup) => { @@ -152,7 +189,11 @@ describe("published screener CRM bridge", () => { const s = setup(); s.child.location.href = `https://bdt.example/screener/id?integrationOrigin=${encodeURIComponent(origin)}`; expect( - createScreeningBridge("id", vi.fn(), s.child as any), + createScreeningBridge( + "id", + { initialize: vi.fn(), onTimeout: vi.fn() }, + s.child as any, + ), ).toBeUndefined(); }); @@ -160,12 +201,95 @@ describe("published screener CRM bridge", () => { const s = setup(true); s.child.opener = null as any; expect( - createScreeningBridge("id", vi.fn(), s.child as any), + createScreeningBridge( + "id", + { initialize: vi.fn(), onTimeout: vi.fn() }, + s.child as any, + ), ).toBeUndefined(); }); }); describe("CRM host adapter", () => { + function connect(s: ReturnType, options = {}) { + const callbacks = { + onResult: vi.fn(), + onError: vi.fn(), + onInitialized: vi.fn(), + }; + const host = createCrmIntegration({ + screenerUrl: "https://bdt.example/screener/published-id", + getTargetWindow: () => s.child as any, + initialData: s.init.inputData, + requestId: "request-1", + hostWindow: s.host as any, + ...callbacks, + ...options, + }); + return { host, ...callbacks }; + } + + it("keeps a session initialized when the screener repeats ready", () => { + const s = setup(); + const c = connect(s); + s.bridge.start(); + const ready = s.hostPost.mock.calls[0][0]; + s.receive(ready); + s.receive(ready); + expect(s.childPost).toHaveBeenCalledTimes(2); + s.send(s.childPost.mock.calls[0][0]); + s.receive(s.hostPost.mock.calls[1][0]); + s.receive(ready); + expect(s.childPost).toHaveBeenCalledTimes(2); + s.bridge.result({}, results); + s.receive(s.hostPost.mock.calls[2][0]); + expect(c.onResult).toHaveBeenCalledOnce(); + c.host.dispose(); + s.bridge.dispose(); + }); + + it("reports a screener answering from another origin once, without sending prefill", () => { + const s = setup(); + const c = connect(s); + s.bridge.start(); + const ready = s.hostPost.mock.calls[0][0]; + s.receive(ready, "https://www.bdt.example"); + s.receive(ready, "https://www.bdt.example"); + expect(c.onError).toHaveBeenCalledExactlyOnceWith({ + code: "ORIGIN_MISMATCH", + origin: "https://www.bdt.example", + }); + expect(s.childPost).not.toHaveBeenCalled(); + c.host.dispose(); + s.bridge.dispose(); + }); + + it("reports a connection timeout unless the screener initializes", () => { + const waiting = setup(); + const timedOut = connect(waiting, { connectTimeoutMs: 1000 }); + vi.advanceTimersByTime(1000); + expect(timedOut.onError).toHaveBeenCalledExactlyOnceWith({ + code: "CONNECTION_TIMEOUT", + }); + timedOut.host.dispose(); + + const s = setup(); + const c = connect(s, { connectTimeoutMs: 1000 }); + s.bridge.start(); + s.receive(s.hostPost.mock.calls[0][0]); + s.send(s.childPost.mock.calls[0][0]); + s.receive(s.hostPost.mock.calls[1][0]); + vi.advanceTimersByTime(1000); + expect(c.onError).not.toHaveBeenCalled(); + c.host.dispose(); + s.bridge.dispose(); + + const disposed = connect(setup(), { connectTimeoutMs: 1000 }); + disposed.host.dispose(); + vi.advanceTimersByTime(1000); + expect(disposed.onError).not.toHaveBeenCalled(); + }); + it("accepts screener URLs under a base path and rejects other paths", () => { const options = { getTargetWindow: () => undefined, @@ -204,16 +328,22 @@ describe("CRM host adapter", () => { hostWindow: s.host as any, }); try { + s.childPost.mockImplementationOnce(() => { + throw new Error("Blocked by host"); + }); s.childPost.mockImplementationOnce(() => { throw new Error("Blocked by host"); }); s.bridge.start(); const ready = s.hostPost.mock.calls[0][0]; s.receive(ready); - expect(onError).toHaveBeenCalledWith({ code: "INITIALIZATION_FAILED" }); + s.receive(ready); + expect(onError).toHaveBeenCalledExactlyOnceWith({ + code: "INITIALIZATION_FAILED", + }); expect(onInitialized).not.toHaveBeenCalled(); s.receive(ready); - s.send(s.childPost.mock.calls[1][0]); + s.send(s.childPost.mock.calls[2][0]); s.receive(s.hostPost.mock.calls[1][0]); expect(onInitialized).toHaveBeenCalledOnce(); } finally { @@ -300,6 +430,10 @@ describe("CRM host adapter", () => { s.receive(ready, "https://bdt.example", {}); s.receive({ ...ready, version: 2 }); expect(s.childPost).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledExactlyOnceWith({ + code: "ORIGIN_MISMATCH", + origin: "https://untrusted.example", + }); s.receive(ready); expect(s.childPost).toHaveBeenCalledWith(s.init, "https://bdt.example"); s.send(s.childPost.mock.calls[0][0]); @@ -315,7 +449,10 @@ describe("CRM host adapter", () => { expect(onResult).toHaveBeenCalledWith(result); s.bridge.error(); s.receive(s.hostPost.mock.calls[3][0]); - expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledTimes(2); + expect(onError).toHaveBeenLastCalledWith( + expect.objectContaining({ code: "EVALUATION_FAILED" }), + ); host.dispose(); s.receive(result); expect(onResult).toHaveBeenCalledOnce(); diff --git a/builder-frontend/src/integrations/screenerBridge.ts b/builder-frontend/src/integrations/screenerBridge.ts index be907952..d363874d 100644 --- a/builder-frontend/src/integrations/screenerBridge.ts +++ b/builder-frontend/src/integrations/screenerBridge.ts @@ -3,6 +3,8 @@ import { isUnsafeObjectKey } from "@/utils/unsafeObjectKeys"; const CHANNEL = "bdt.crm"; const VERSION = 1; +const READY_INTERVAL_MS = 500; +export const CONNECT_TIMEOUT_MS = 10_000; // Prefill is JSON form data, never executable content or prototype properties. function isJsonData(value: unknown, depth = 0): boolean { @@ -29,7 +31,14 @@ export interface ScreeningBridge { /** Opt in using an exact host origin. Only the parent/opener can initialize. */ export function createScreeningBridge( screenerId: string, - initialize: (data: Record) => void, + { + initialize, + onTimeout, + }: { + initialize: (data: Record) => void; + /** No host initialized the screener in time; the bridge has stopped. */ + onTimeout: () => void; + }, browser: Window = window, ): ScreeningBridge | undefined { const originParam = new URL(browser.location.href).searchParams.get( @@ -60,6 +69,12 @@ export function createScreeningBridge( let requestId: string | undefined; let active = false; let jsonMessages = false; + let readyRetry: ReturnType | undefined; + let connectTimeout: ReturnType | undefined; + const stopWaiting = () => { + clearInterval(readyRetry); + clearTimeout(connectTimeout); + }; const send = (type: string, payload: Record = {}) => { if (!active) return; const message = { @@ -106,23 +121,34 @@ export function createScreeningBridge( ) return; + stopWaiting(); requestId = message.requestId; jsonMessages = typeof event.data === "string"; initialize(structuredClone(message.inputData)); send("initialized"); }; + const dispose = () => { + stopWaiting(); + active = false; + browser.removeEventListener("message", receive); + }; + return { start() { if (active) return; active = true; browser.addEventListener("message", receive); send("ready"); + // Repeat ready for hosts that start listening late. Give up when no host + // answers, e.g. after a reload once the host has disposed its connection. + readyRetry = setInterval(() => send("ready"), READY_INTERVAL_MS); + connectTimeout = setTimeout(() => { + dispose(); + onTimeout(); + }, CONNECT_TIMEOUT_MS); }, - dispose() { - active = false; - browser.removeEventListener("message", receive); - }, + dispose, result(inputData, results) { if (requestId) send("result", { diff --git a/docs/src/content/docs/dev/crm-integrations.md b/docs/src/content/docs/dev/crm-integrations.md index 6efb4543..d8bcc75b 100644 --- a/docs/src/content/docs/dev/crm-integrations.md +++ b/docs/src/content/docs/dev/crm-integrations.md @@ -99,7 +99,15 @@ frame.src = connection.url; ``` The host helper functions in this snippet (`showScreeningResults` and -`showEvaluationError`) belong to your CRM UI. Downloading and bundling the adapter +`showEvaluationError`) belong to your CRM UI. `onError` receives a `code`: + +| Code | Meaning | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `INITIALIZATION_FAILED` | The browser blocked sending prefill to the screener window | +| `CONNECTION_TIMEOUT` | No screener connected within `connectTimeoutMs` (default 15 seconds), e.g. a wrong URL or a blocked frame/window | +| `ORIGIN_MISMATCH` | The screener answered from another origin, given as `origin`, often after a redirect; no prefill was sent | +| `EVALUATION_FAILED` | An evaluation failed; a later successful result replaces it | + Downloading and bundling the adapter also avoids a dependency on cross-origin module fetching permissions. For a separate tab/window, keep a reference to the opened window: @@ -152,12 +160,18 @@ Every message is a plain object with these envelope fields: | Direction | Type | Additional fields / behavior | | --------------- | ------------- | ------------------------------------------------------------- | -| Screener → host | `ready` | Host can now send prefill using this `sessionId` | +| Screener → host | `ready` | Repeated until initialized; send prefill for this `sessionId` | | Host → screener | `initialize` | `inputData`: a JSON form-data object; use `{}` for no prefill | | Screener → host | `initialized` | Prefill accepted; form loads and evaluates | | Screener → host | `result` | `inputData`, `results`, `evaluatedAt` (ISO 8601 timestamp) | | Screener → host | `error` | `code: "EVALUATION_FAILED"` | +The screener repeats `ready` about every 500 ms until it's initialized, so a +host that starts listening late still connects. Ignore repeated `ready` messages +for a session you've already initialized. If nothing initializes the screener +within 10 seconds, it stops sending messages. It then shows the form unconnected, +with a notice that results won't be sent back. + The screener waits for initialization before displaying the connected form. It accepts one initialization per page load and rejects malformed data, unsafe prototype keys, mismatched origins/windows, and mismatched sessions. To start diff --git a/examples/crm/browser/demo.js b/examples/crm/browser/demo.js index 0c2c2944..d03e0dd9 100644 --- a/examples/crm/browser/demo.js +++ b/examples/crm/browser/demo.js @@ -6,6 +6,13 @@ const frame = element("screener"); const frameViewport = element("screener-viewport"); const client = { id: "demo-client-1", name: "Alex Example", screening: null }; const storageKey = "bdt.crm.browser-demo.record.v1"; +const connectionErrors = { + INITIALIZATION_FAILED: () => "Could not connect to the screener. Try again.", + CONNECTION_TIMEOUT: () => + "The screener didn’t connect. Check the screener URL and try again.", + ORIGIN_MISMATCH: ({ origin }) => + `The screener URL redirects to ${origin}. Use that address instead.`, +}; let connection; let popup; let latest; @@ -131,10 +138,11 @@ function start(popupMode) { `Unsaved results: ${counts.eligible} eligible, ${counts.ineligible} ineligible, ${counts.unknown} need information. ` + "Review them in the screener, then save to the demo record."; }, - onError: () => { + onError: (message) => { latest = undefined; element("save").disabled = true; element("status").textContent = + connectionErrors[message.code]?.(message) ?? "Evaluation failed. Change an answer to retry."; }, }); diff --git a/examples/crm/salesforce/bdtScreening/bdtScreening.js b/examples/crm/salesforce/bdtScreening/bdtScreening.js index f4ad57d6..b66f0aa5 100644 --- a/examples/crm/salesforce/bdtScreening/bdtScreening.js +++ b/examples/crm/salesforce/bdtScreening/bdtScreening.js @@ -31,6 +31,15 @@ const OPTIONAL_INTAKE_FIELDS = [ ENROLLED_PROGRAMS, ]; +const CONNECTION_ERRORS = { + INITIALIZATION_FAILED: () => + "Could not connect to the screener. Try Screen here or open a new screening window.", + CONNECTION_TIMEOUT: () => + "The screener didn’t connect. Check the screener URL in App Builder and that Salesforce can frame or open it.", + ORIGIN_MISMATCH: ({ origin }) => + `The screener URL redirects to ${origin}. Use that address in App Builder.`, +}; + export default class BdtScreening extends LightningElement { _recordId; @api @@ -105,9 +114,8 @@ export default class BdtScreening extends LightningElement { if (this.recordId !== recordId) return; this.latest = undefined; this.status = - message.code === "INITIALIZATION_FAILED" - ? "Could not connect to the screener. Try Screen here or open a new screening window." - : "Evaluation failed. Change an answer to retry."; + CONNECTION_ERRORS[message.code]?.(message) ?? + "Evaluation failed. Change an answer to retry."; }, }); this.status = "Waiting for screening results…"; From e44a6921f768afc38ef83dd0dc641f8fb0af8733 Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Fri, 9 Oct 2026 21:56:04 -0400 Subject: [PATCH 07/10] Tell CRM hosts when a published screener can't load If fetching the published screener failed, the bridge had already sent initialized and the host kept waiting for results that never came. The screener now replies to initialize with a SCREENER_UNAVAILABLE error, or sends it once loading fails, and shows a load error instead of throwing. Co-Authored-By: Claude Opus 5.5 --- builder-frontend/public/integrations/host.js | 9 +++- .../src/components/screener/Screener.test.tsx | 21 +++++++- .../src/components/screener/Screener.tsx | 15 ++++-- .../src/integrations/screenerBridge.test.ts | 48 +++++++++++++++++++ .../src/integrations/screenerBridge.ts | 12 +++++ docs/src/content/docs/dev/crm-integrations.md | 7 ++- examples/crm/browser/demo.js | 2 + .../salesforce/bdtScreening/bdtScreening.js | 2 + 8 files changed, 110 insertions(+), 6 deletions(-) diff --git a/builder-frontend/public/integrations/host.js b/builder-frontend/public/integrations/host.js index f688400d..2979c56a 100644 --- a/builder-frontend/public/integrations/host.js +++ b/builder-frontend/public/integrations/host.js @@ -25,7 +25,8 @@ function randomId() { * onError receives `{ code }`: INITIALIZATION_FAILED, CONNECTION_TIMEOUT (no * screener initialized within connectTimeoutMs), ORIGIN_MISMATCH (the screener * answered from another origin, such as after a redirect; includes `origin`), - * or EVALUATION_FAILED. + * SCREENER_UNAVAILABLE (the published screener couldn't be loaded), or + * EVALUATION_FAILED. */ export function createCrmIntegration({ screenerUrl, @@ -141,6 +142,12 @@ export function createCrmIntegration({ typeof message.evaluatedAt === "string" ) { onResult(message); + } else if ( + message.type === "error" && + message.code === "SCREENER_UNAVAILABLE" + ) { + clearTimeout(connectTimer); + onError(message); } else if ( initialized && message.type === "error" && diff --git a/builder-frontend/src/components/screener/Screener.test.tsx b/builder-frontend/src/components/screener/Screener.test.tsx index 715f51ea..e6a44666 100644 --- a/builder-frontend/src/components/screener/Screener.test.tsx +++ b/builder-frontend/src/components/screener/Screener.test.tsx @@ -3,7 +3,10 @@ import { onMount } from "solid-js"; import { render } from "solid-js/web"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import Screener from "./Screener"; -import { evaluatePublishedScreener } from "@/api/publishedScreener"; +import { + evaluatePublishedScreener, + fetchPublishedScreener, +} from "@/api/publishedScreener"; import type { ScreenerResult } from "@/types"; const integration = vi.hoisted(() => ({ @@ -16,6 +19,7 @@ const integration = vi.hoisted(() => ({ dispose: vi.fn(), result: vi.fn(), error: vi.fn(), + unavailable: vi.fn(), })); vi.mock("@solidjs/router", () => ({ @@ -132,6 +136,21 @@ describe("published screener integration lifecycle", () => { expect(integration.result).not.toHaveBeenCalled(); }); + it("tells the user and the host when the screener can't be loaded", async () => { + vi.mocked(fetchPublishedScreener).mockRejectedValueOnce( + new Error("Fetch failed with status: 404"), + ); + dispose = render(() => , container); + await vi.waitFor(() => + expect(integration.unavailable).toHaveBeenCalledOnce(), + ); + expect(container.textContent).toContain("couldn’t be loaded"); + expect(container.textContent).not.toContain("Waiting for information"); + integration.initialize!(initialData); + await Promise.resolve(); + expect(container.querySelector("button")).toBeNull(); + }); + it("offers the form unconnected when no host initializes it", async () => { vi.mocked(evaluatePublishedScreener).mockResolvedValue(result("TRUE")); dispose = render(() => , container); diff --git a/builder-frontend/src/components/screener/Screener.tsx b/builder-frontend/src/components/screener/Screener.tsx index acbe0fb7..785fe665 100644 --- a/builder-frontend/src/components/screener/Screener.tsx +++ b/builder-frontend/src/components/screener/Screener.tsx @@ -2,6 +2,7 @@ import { createSignal, createResource, createMemo, + createEffect, Show, onMount, onCleanup, @@ -50,6 +51,9 @@ export default function Screener() { const [integrationReady, setIntegrationReady] = createSignal(!bridge); let evaluationSequence = 0; onMount(() => bridge?.start()); + createEffect(() => { + if (screener.error) bridge?.unavailable(); + }); onCleanup(() => { evaluationSequence++; bridge?.dispose(); @@ -95,19 +99,24 @@ export default function Screener() { return (
    {screener.loading && } - + + + +

    Waiting for information from the connected application…

    - + - {screener() && integrationReady() && ( + {!screener.error && screener() && integrationReady() && (
    { s.bridge.dispose(); }); + it("reports an unavailable screener in place of initialized, or after it", () => { + const before = setup(); + before.bridge.start(); + before.bridge.unavailable(); + before.send(); + expect(before.initialize).not.toHaveBeenCalled(); + expect(before.hostPost).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: "error", + code: "SCREENER_UNAVAILABLE", + requestId: "request-1", + }), + "https://crm.example", + ); + expect(before.hostPost).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "initialized" }), + expect.anything(), + ); + before.bridge.dispose(); + + const after = setup(); + after.bridge.start(); + after.send(); + after.bridge.unavailable(); + expect(after.hostPost).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "error", code: "SCREENER_UNAVAILABLE" }), + "https://crm.example", + ); + after.bridge.dispose(); + }); + it("stops and reports a timeout when no host initializes it", () => { const s = setup(true); s.bridge.start(); @@ -264,6 +295,23 @@ describe("CRM host adapter", () => { s.bridge.dispose(); }); + it("reports an unavailable screener before initialization", () => { + const s = setup(); + const c = connect(s, { connectTimeoutMs: 1000 }); + s.bridge.start(); + s.bridge.unavailable(); + s.receive(s.hostPost.mock.calls[0][0]); + s.send(s.childPost.mock.calls[0][0]); + s.receive(s.hostPost.mock.calls[1][0]); + vi.advanceTimersByTime(1000); + expect(c.onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: "SCREENER_UNAVAILABLE" }), + ); + expect(c.onInitialized).not.toHaveBeenCalled(); + c.host.dispose(); + s.bridge.dispose(); + }); + it("reports a connection timeout unless the screener initializes", () => { const waiting = setup(); const timedOut = connect(waiting, { connectTimeoutMs: 1000 }); diff --git a/builder-frontend/src/integrations/screenerBridge.ts b/builder-frontend/src/integrations/screenerBridge.ts index d363874d..2b0f688d 100644 --- a/builder-frontend/src/integrations/screenerBridge.ts +++ b/builder-frontend/src/integrations/screenerBridge.ts @@ -26,6 +26,8 @@ export interface ScreeningBridge { dispose(): void; result(inputData: Record, results: ScreenerResult): void; error(): void; + /** The published screener couldn't be loaded. */ + unavailable(): void; } /** Opt in using an exact host origin. Only the parent/opener can initialize. */ @@ -69,6 +71,7 @@ export function createScreeningBridge( let requestId: string | undefined; let active = false; let jsonMessages = false; + let isUnavailable = false; let readyRetry: ReturnType | undefined; let connectTimeout: ReturnType | undefined; const stopWaiting = () => { @@ -124,6 +127,10 @@ export function createScreeningBridge( stopWaiting(); requestId = message.requestId; jsonMessages = typeof event.data === "string"; + if (isUnavailable) { + send("error", { code: "SCREENER_UNAVAILABLE" }); + return; + } initialize(structuredClone(message.inputData)); send("initialized"); }; @@ -160,5 +167,10 @@ export function createScreeningBridge( error() { if (requestId) send("error", { code: "EVALUATION_FAILED" }); }, + unavailable() { + // Before initialization, the reply to initialize reports this instead. + isUnavailable = true; + if (requestId) send("error", { code: "SCREENER_UNAVAILABLE" }); + }, }; } diff --git a/docs/src/content/docs/dev/crm-integrations.md b/docs/src/content/docs/dev/crm-integrations.md index d8bcc75b..52155b39 100644 --- a/docs/src/content/docs/dev/crm-integrations.md +++ b/docs/src/content/docs/dev/crm-integrations.md @@ -106,6 +106,7 @@ The host helper functions in this snippet (`showScreeningResults` and | `INITIALIZATION_FAILED` | The browser blocked sending prefill to the screener window | | `CONNECTION_TIMEOUT` | No screener connected within `connectTimeoutMs` (default 15 seconds), e.g. a wrong URL or a blocked frame/window | | `ORIGIN_MISMATCH` | The screener answered from another origin, given as `origin`, often after a redirect; no prefill was sent | +| `SCREENER_UNAVAILABLE` | The published screener couldn't be loaded, e.g. it was unpublished or the ID is wrong | | `EVALUATION_FAILED` | An evaluation failed; a later successful result replaces it | Downloading and bundling the adapter also avoids a dependency on cross-origin module fetching permissions. @@ -164,7 +165,7 @@ Every message is a plain object with these envelope fields: | Host → screener | `initialize` | `inputData`: a JSON form-data object; use `{}` for no prefill | | Screener → host | `initialized` | Prefill accepted; form loads and evaluates | | Screener → host | `result` | `inputData`, `results`, `evaluatedAt` (ISO 8601 timestamp) | -| Screener → host | `error` | `code: "EVALUATION_FAILED"` | +| Screener → host | `error` | `code`: `"EVALUATION_FAILED"` or `"SCREENER_UNAVAILABLE"` | The screener repeats `ready` about every 500 ms until it's initialized, so a host that starts listening late still connects. Ignore repeated `ready` messages @@ -183,6 +184,10 @@ A checkbox or text field can't display an unknown answer, so it appears unchecked or empty. The connected screener still evaluates and returns a `null` or omitted prefill answer as `null` until the user changes that field. +If the published screener can't be loaded, the screener replies to `initialize` +with `error` and `code: "SCREENER_UNAVAILABLE"` instead of `initialized`. If +loading fails after initialization, it sends that error then. + `result` is sent after initial evaluation and after evaluated edits (normally debounced by one second). Multiple results per session are expected. Older in-flight responses are discarded once a newer evaluation has started. A diff --git a/examples/crm/browser/demo.js b/examples/crm/browser/demo.js index d03e0dd9..86c55a38 100644 --- a/examples/crm/browser/demo.js +++ b/examples/crm/browser/demo.js @@ -12,6 +12,8 @@ const connectionErrors = { "The screener didn’t connect. Check the screener URL and try again.", ORIGIN_MISMATCH: ({ origin }) => `The screener URL redirects to ${origin}. Use that address instead.`, + SCREENER_UNAVAILABLE: () => + "The screener couldn’t be loaded. Check that it’s published and the URL is correct.", }; let connection; let popup; diff --git a/examples/crm/salesforce/bdtScreening/bdtScreening.js b/examples/crm/salesforce/bdtScreening/bdtScreening.js index b66f0aa5..abd88a46 100644 --- a/examples/crm/salesforce/bdtScreening/bdtScreening.js +++ b/examples/crm/salesforce/bdtScreening/bdtScreening.js @@ -38,6 +38,8 @@ const CONNECTION_ERRORS = { "The screener didn’t connect. Check the screener URL in App Builder and that Salesforce can frame or open it.", ORIGIN_MISMATCH: ({ origin }) => `The screener URL redirects to ${origin}. Use that address in App Builder.`, + SCREENER_UNAVAILABLE: () => + "The screener couldn’t be loaded. Check that it’s published and the screener URL in App Builder.", }; export default class BdtScreening extends LightningElement { From ed9f9da72a897ae6701679f6847e5aea3e0efaf4 Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Fri, 9 Oct 2026 21:58:43 -0400 Subject: [PATCH 08/10] Tell open screeners when their CRM connection closes When the Salesforce component's record changed, or staff started another screening, the host disposed its connection but the old popup kept showing live results that nobody received. The host adapter's dispose() now sends disconnect, and the screener stops sending results and tells the user it's no longer connected. This covers any host, not only Salesforce. Co-Authored-By: Claude Opus 5.5 --- builder-frontend/public/integrations/host.js | 37 +++++++++----- .../src/components/screener/Screener.test.tsx | 26 ++++++++-- .../src/components/screener/Screener.tsx | 27 +++++----- .../src/integrations/screenerBridge.test.ts | 49 ++++++++++++++++--- .../src/integrations/screenerBridge.ts | 33 ++++++++----- docs/src/content/docs/dev/crm-integrations.md | 5 ++ e2e/tests/crmIntegration.spec.ts | 20 ++++++++ 7 files changed, 149 insertions(+), 48 deletions(-) diff --git a/builder-frontend/public/integrations/host.js b/builder-frontend/public/integrations/host.js index 2979c56a..e61caaac 100644 --- a/builder-frontend/public/integrations/host.js +++ b/builder-frontend/public/integrations/host.js @@ -67,6 +67,21 @@ export function createCrmIntegration({ const connectTimer = setTimeout(() => { if (!initialized) onError({ code: "CONNECTION_TIMEOUT" }); }, connectTimeoutMs); + const send = (target, type, payload = {}) => { + const message = { + channel: "bdt.crm", + version: 1, + type, + screenerId, + sessionId, + requestId, + ...payload, + }; + target.postMessage( + serializeMessages ? JSON.stringify(message) : message, + url.origin, + ); + }; const receive = (event) => { const target = getTargetWindow(); if (!target || event.source !== target) return; @@ -102,20 +117,8 @@ export function createCrmIntegration({ sessionId = message.sessionId; initialized = false; } - const initialization = { - channel: "bdt.crm", - version: 1, - type: "initialize", - screenerId, - sessionId, - requestId, - inputData: initialData, - }; try { - target.postMessage( - serializeMessages ? JSON.stringify(initialization) : initialization, - url.origin, - ); + send(target, "initialize", { inputData: initialData }); } catch (error) { if (failedSessionId === sessionId) return; failedSessionId = sessionId; @@ -162,6 +165,14 @@ export function createCrmIntegration({ dispose() { clearTimeout(connectTimer); hostWindow.removeEventListener("message", receive); + // Tell an open screener that its answers no longer reach this host. + const target = getTargetWindow(); + if (!sessionId || !target) return; + try { + send(target, "disconnect"); + } catch { + // The screener window may already be closed or unreachable. + } }, }; } diff --git a/builder-frontend/src/components/screener/Screener.test.tsx b/builder-frontend/src/components/screener/Screener.test.tsx index e6a44666..388036d6 100644 --- a/builder-frontend/src/components/screener/Screener.test.tsx +++ b/builder-frontend/src/components/screener/Screener.test.tsx @@ -14,7 +14,7 @@ const integration = vi.hoisted(() => ({ initialize: undefined as | undefined | ((data: Record) => void), - timeout: undefined as undefined | (() => void), + disconnect: undefined as undefined | ((reason: string) => void), start: vi.fn(), dispose: vi.fn(), result: vi.fn(), @@ -35,10 +35,13 @@ vi.mock("@/api/publishedScreener", () => ({ vi.mock("@/integrations/screenerBridge", () => ({ createScreeningBridge: ( _: string, - callbacks: { initialize: () => void; onTimeout: () => void }, + callbacks: { + initialize: () => void; + onDisconnect: (reason: string) => void; + }, ) => { integration.initialize = callbacks.initialize; - integration.timeout = callbacks.onTimeout; + integration.disconnect = callbacks.onDisconnect; return integration.enabled ? integration : undefined; }, })); @@ -155,7 +158,7 @@ describe("published screener integration lifecycle", () => { vi.mocked(evaluatePublishedScreener).mockResolvedValue(result("TRUE")); dispose = render(() => , container); await vi.waitFor(() => expect(integration.start).toHaveBeenCalledOnce()); - integration.timeout!(); + integration.disconnect!("timeout"); await vi.waitFor(() => expect(container.querySelector("button")).not.toBeNull(), ); @@ -164,6 +167,21 @@ describe("published screener integration lifecycle", () => { expect(evaluatePublishedScreener).not.toHaveBeenCalled(); }); + it("keeps the form after the host closes the connection and says so", async () => { + vi.mocked(evaluatePublishedScreener).mockResolvedValue(result("TRUE")); + dispose = render(() => , container); + integration.initialize!(initialData); + await vi.waitFor(() => + expect(evaluatePublishedScreener).toHaveBeenCalledOnce(), + ); + integration.disconnect!("closed"); + await vi.waitFor(() => + expect(container.textContent).toContain("no longer connected"), + ); + expect(container.querySelector("button")).not.toBeNull(); + expect(evaluatePublishedScreener).toHaveBeenCalledOnce(); + }); + it("keeps the standalone form available without waiting or initial evaluation", async () => { integration.enabled = false; vi.mocked(evaluatePublishedScreener).mockResolvedValue(result("TRUE")); diff --git a/builder-frontend/src/components/screener/Screener.tsx b/builder-frontend/src/components/screener/Screener.tsx index 785fe665..a05cb6c2 100644 --- a/builder-frontend/src/components/screener/Screener.tsx +++ b/builder-frontend/src/components/screener/Screener.tsx @@ -36,15 +36,17 @@ export default function Screener() { ); const [screenerResult, setScreenerResult] = createSignal(); const [formData, setFormData] = createSignal({}); - const [integrationFailed, setIntegrationFailed] = createSignal(false); + const [disconnectReason, setDisconnectReason] = createSignal< + "timeout" | "closed" + >(); const bridge = createScreeningBridge(params.publishedScreenerId, { initialize: (data) => { setFormData(data); setIntegrationReady(true); }, - // Let the user screen without the host rather than wait forever. - onTimeout: () => { - setIntegrationFailed(true); + // Keep the form usable, but make clear results no longer reach the host. + onDisconnect: (reason) => { + setDisconnectReason(reason); setIntegrationReady(true); }, }); @@ -109,12 +111,15 @@ export default function Screener() { Waiting for information from the connected application…

    - - + + {(reason) => ( + + )} {!screener.error && screener() && integrationReady() && (
    @@ -124,7 +129,7 @@ export default function Screener() { formData={formData} hiddenQuestionPaths={hiddenQuestionPaths} submitForm={submitForm} - evaluateInitialData={!!bridge && !integrationFailed()} + evaluateInitialData={!!bridge && !disconnectReason()} onDataChange={() => { evaluationSequence++; setResultsStale(true); diff --git a/builder-frontend/src/integrations/screenerBridge.test.ts b/builder-frontend/src/integrations/screenerBridge.test.ts index c65ad557..8fee6028 100644 --- a/builder-frontend/src/integrations/screenerBridge.test.ts +++ b/builder-frontend/src/integrations/screenerBridge.test.ts @@ -24,10 +24,10 @@ function setup(popup = false) { }); if (popup) child.parent = child as any; const initialize = vi.fn(); - const onTimeout = vi.fn(); + const onDisconnect = vi.fn(); const bridge = createScreeningBridge( "published-id", - { initialize, onTimeout }, + { initialize, onDisconnect }, child as any, )!; const dispatch = ( @@ -52,7 +52,7 @@ function setup(popup = false) { return { bridge, initialize, - onTimeout, + onDisconnect, child, host, init, @@ -91,7 +91,7 @@ describe("published screener CRM bridge", () => { ); vi.advanceTimersByTime(CONNECT_TIMEOUT_MS); expect(s.hostPost).toHaveBeenCalledTimes(4); - expect(s.onTimeout).not.toHaveBeenCalled(); + expect(s.onDisconnect).not.toHaveBeenCalled(); s.bridge.dispose(); }); @@ -126,11 +126,27 @@ describe("published screener CRM bridge", () => { after.bridge.dispose(); }); + it("stops when its host closes the connection", () => { + const s = setup(true); + s.bridge.start(); + s.send(); + const { inputData: _, ...disconnect } = { ...s.init, type: "disconnect" }; + s.send({ ...disconnect, requestId: "other" } as any); + s.send(disconnect as any, "https://untrusted.example"); + s.send(disconnect as any, "https://crm.example", {}); + expect(s.onDisconnect).not.toHaveBeenCalled(); + s.send(disconnect as any); + expect(s.onDisconnect).toHaveBeenCalledExactlyOnceWith("closed"); + const sent = s.hostPost.mock.calls.length; + s.bridge.result({}, results); + expect(s.hostPost).toHaveBeenCalledTimes(sent); + }); + it("stops and reports a timeout when no host initializes it", () => { const s = setup(true); s.bridge.start(); vi.advanceTimersByTime(CONNECT_TIMEOUT_MS); - expect(s.onTimeout).toHaveBeenCalledOnce(); + expect(s.onDisconnect).toHaveBeenCalledExactlyOnceWith("timeout"); const readyCount = s.hostPost.mock.calls.length; vi.advanceTimersByTime(CONNECT_TIMEOUT_MS); s.send(); @@ -222,7 +238,7 @@ describe("published screener CRM bridge", () => { expect( createScreeningBridge( "id", - { initialize: vi.fn(), onTimeout: vi.fn() }, + { initialize: vi.fn(), onDisconnect: vi.fn() }, s.child as any, ), ).toBeUndefined(); @@ -234,7 +250,7 @@ describe("published screener CRM bridge", () => { expect( createScreeningBridge( "id", - { initialize: vi.fn(), onTimeout: vi.fn() }, + { initialize: vi.fn(), onDisconnect: vi.fn() }, s.child as any, ), ).toBeUndefined(); @@ -295,6 +311,25 @@ describe("CRM host adapter", () => { s.bridge.dispose(); }); + it("tells an open screener when the connection is disposed", () => { + const s = setup(true); + connect(s).host.dispose(); + expect(s.childPost).not.toHaveBeenCalled(); + const c = connect(s, { serializeMessages: true }); + s.bridge.start(); + s.receive(s.hostPost.mock.calls[0][0]); + s.send(s.childPost.mock.calls[0][0]); + c.host.dispose(); + const disconnect = s.childPost.mock.calls[1][0]; + expect(JSON.parse(disconnect)).toMatchObject({ + type: "disconnect", + sessionId: "01".repeat(16), + requestId: "request-1", + }); + s.send(disconnect); + expect(s.onDisconnect).toHaveBeenCalledExactlyOnceWith("closed"); + }); + it("reports an unavailable screener before initialization", () => { const s = setup(); const c = connect(s, { connectTimeoutMs: 1000 }); diff --git a/builder-frontend/src/integrations/screenerBridge.ts b/builder-frontend/src/integrations/screenerBridge.ts index 2b0f688d..7eb44f8c 100644 --- a/builder-frontend/src/integrations/screenerBridge.ts +++ b/builder-frontend/src/integrations/screenerBridge.ts @@ -35,11 +35,14 @@ export function createScreeningBridge( screenerId: string, { initialize, - onTimeout, + onDisconnect, }: { initialize: (data: Record) => void; - /** No host initialized the screener in time; the bridge has stopped. */ - onTimeout: () => void; + /** + * The bridge has stopped: no host initialized it in time ("timeout"), or + * the host closed its connection ("closed"). + */ + onDisconnect: (reason: "timeout" | "closed") => void; }, browser: Window = window, ): ScreeningBridge | undefined { @@ -93,13 +96,7 @@ export function createScreeningBridge( }; const receive = (event: MessageEvent) => { - if ( - !active || - requestId || - event.origin !== origin || - event.source !== host - ) - return; + if (!active || event.origin !== origin || event.source !== host) return; let message = event.data; if (typeof message === "string") { try { @@ -112,9 +109,19 @@ export function createScreeningBridge( !message || message.channel !== CHANNEL || message.version !== VERSION || - message.type !== "initialize" || message.screenerId !== screenerId || - message.sessionId !== sessionId || + message.sessionId !== sessionId + ) + return; + if (message.type === "disconnect") { + if (requestId && message.requestId !== requestId) return; + dispose(); + onDisconnect("closed"); + return; + } + if ( + requestId || + message.type !== "initialize" || typeof message.requestId !== "string" || !message.requestId || message.requestId.length > 200 || @@ -152,7 +159,7 @@ export function createScreeningBridge( readyRetry = setInterval(() => send("ready"), READY_INTERVAL_MS); connectTimeout = setTimeout(() => { dispose(); - onTimeout(); + onDisconnect("timeout"); }, CONNECT_TIMEOUT_MS); }, dispose, diff --git a/docs/src/content/docs/dev/crm-integrations.md b/docs/src/content/docs/dev/crm-integrations.md index 52155b39..c9a7ab39 100644 --- a/docs/src/content/docs/dev/crm-integrations.md +++ b/docs/src/content/docs/dev/crm-integrations.md @@ -98,6 +98,10 @@ frame.src = connection.url; // Call connection.dispose() when the host view is closed or its client changes. ``` +`dispose()` also sends `disconnect` to an open screener. The screener then stops +sending results and tells the user it's no longer connected, so staff don't keep +working in a window whose answers the CRM ignores. + The host helper functions in this snippet (`showScreeningResults` and `showEvaluationError`) belong to your CRM UI. `onError` receives a `code`: @@ -166,6 +170,7 @@ Every message is a plain object with these envelope fields: | Screener → host | `initialized` | Prefill accepted; form loads and evaluates | | Screener → host | `result` | `inputData`, `results`, `evaluatedAt` (ISO 8601 timestamp) | | Screener → host | `error` | `code`: `"EVALUATION_FAILED"` or `"SCREENER_UNAVAILABLE"` | +| Host → screener | `disconnect` | Host closed the connection; screener stops and says so | The screener repeats `ready` about every 500 ms until it's initialized, so a host that starts listening late still connects. Ignore repeated `ready` messages diff --git a/e2e/tests/crmIntegration.spec.ts b/e2e/tests/crmIntegration.spec.ts index 7a5ffc89..58cc9bf3 100644 --- a/e2e/tests/crmIntegration.spec.ts +++ b/e2e/tests/crmIntegration.spec.ts @@ -259,6 +259,26 @@ for (const serializeMessages of [false, true]) { } } +test("an old screening window says when its CRM connection closes", async ({ + page, + baseURL, +}) => { + await page.goto(demoUrl); + await page + .locator("#screener-url") + .fill(new URL("/screener/crm-test", baseURL).href); + const popupPromise = page.waitForEvent("popup"); + await page.locator("#popup").click(); + const popup = await popupPromise; + await expect(popup.getByLabel("Household income")).toBeVisible({ + timeout: 15000, + }); + await expect(popup.getByRole("alert")).toBeHidden(); + // Starting another screening disposes the window's connection. + await page.locator("#embed").click(); + await expect(popup.getByRole("alert")).toContainText("no longer connected"); +}); + test("standalone screener still evaluates edited answers", async ({ page }) => { await page.goto("/screener/crm-test"); await expect( From ed5db9a8ec556236049b7a96b3a5ca0fcc7129fb Mon Sep 17 00:00:00 2001 From: Preston Cabe Date: Fri, 9 Oct 2026 22:08:13 -0400 Subject: [PATCH 09/10] Only connect screeners to CRM origins their builder allows Any site could pass its own integrationOrigin, open or frame a published screener, and receive every answer and result the person entered. Builders now list allowed CRM origins in the Publish tab. They are saved with the screener in normalized browser form, copied on publish, and returned with the published screener. The screener starts its bridge once that list has loaded. A host whose origin isn't listed gets an ORIGIN_NOT_ALLOWED reply and nothing else, while the person still sees a working standalone form with a notice. Co-Authored-By: Claude Opus 5.5 --- .../builder/controller/ScreenerResource.java | 4 + .../bdt/builder/model/domain/Screener.java | 64 +++++++++++++ .../dto/screener/EditScreenerRequest.java | 9 +- .../controller/ScreenerResourceTest.java | 33 ++++++- builder-frontend/public/integrations/host.js | 9 +- builder-frontend/src/api/screener.ts | 5 +- .../src/components/screener/Screener.test.tsx | 50 +++++++++- .../src/components/screener/Screener.tsx | 35 +++++-- .../IntegrationOrigins.test.tsx | 69 ++++++++++++++ .../screenerEditor/IntegrationOrigins.tsx | 78 ++++++++++++++++ .../src/components/screenerEditor/Publish.tsx | 12 ++- .../screenerEditor/ScreenerEditor.tsx | 10 ++ .../src/integrations/screenerBridge.test.ts | 93 ++++++++++--------- .../src/integrations/screenerBridge.ts | 22 +++-- builder-frontend/src/types.ts | 2 + docs/src/content/docs/dev/crm-integrations.md | 27 ++++-- e2e/tests/crmIntegration.spec.ts | 29 ++++++ examples/crm/browser/README.md | 3 +- examples/crm/browser/demo.js | 2 + examples/crm/salesforce/README.md | 5 +- .../salesforce/bdtScreening/bdtScreening.js | 2 + 21 files changed, 478 insertions(+), 85 deletions(-) create mode 100644 builder-frontend/src/components/screenerEditor/IntegrationOrigins.test.tsx create mode 100644 builder-frontend/src/components/screenerEditor/IntegrationOrigins.tsx diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java index 95c416f6..711e6f80 100644 --- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java @@ -192,6 +192,10 @@ public Response updateScreener( } try { + if (request.integrationOrigins() != null) { + screener.setIntegrationOrigins( + Screener.normalizeIntegrationOrigins(request.integrationOrigins())); + } if (renamed) screenerRepository.renameWorkingScreener(screener); else screenerRepository.updateWorkingScreener(screener); return Response.ok(screener, MediaType.APPLICATION_JSON).build(); diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/model/domain/Screener.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/model/domain/Screener.java index 2016fb2d..d8e8a79c 100644 --- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/model/domain/Screener.java +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/model/domain/Screener.java @@ -1,11 +1,19 @@ package org.codeforphilly.bdt.builder.model.domain; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.ws.rs.BadRequestException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; @JsonIgnoreProperties(ignoreUnknown = true) public class Screener { + private static final int MAX_INTEGRATION_ORIGINS = 20; + /* Screener metadata */ private String id; private String ownerId; @@ -19,6 +27,9 @@ public class Screener { private String publishedScreenerId; private String lastPublishDate; + /* CRM origins allowed to prefill the published screener and receive its results */ + private List integrationOrigins; + public Screener(Map model) { this.formSchema = model; } @@ -39,6 +50,51 @@ public static String normalizeName(String name) { return name == null ? "" : name.strip().toLowerCase(java.util.Locale.ROOT); } + /* + * Browsers compare origins in their serialized form (lowercase, no default + * port, no trailing slash), so store each CRM origin that way. + */ + public static List normalizeIntegrationOrigins(List origins) { + LinkedHashSet normalized = new LinkedHashSet<>(); + for (String origin : origins) { + String value = origin == null ? "" : origin.strip(); + if (!value.isEmpty()) normalized.add(normalizeOrigin(value)); + } + if (normalized.size() > MAX_INTEGRATION_ORIGINS) + throw new BadRequestException( + "Enter at most " + MAX_INTEGRATION_ORIGINS + " CRM origins."); + return new ArrayList<>(normalized); + } + + private static String normalizeOrigin(String value) { + BadRequestException invalid = new BadRequestException( + "Enter each CRM origin as https://host or https://host:port, without a path: " + + value); + URI uri; + try { + uri = new URI(value); + } catch (URISyntaxException e) { + throw invalid; + } + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + String path = uri.getRawPath(); + if (value.length() > 200 + || !(scheme.equals("https") || scheme.equals("http")) + || uri.getHost() == null + || uri.getRawUserInfo() != null + || uri.getRawQuery() != null + || uri.getRawFragment() != null + || !(path == null || path.isEmpty() || path.equals("/"))) { + throw invalid; + } + int port = uri.getPort(); + boolean defaultPort = port == -1 + || (scheme.equals("https") && port == 443) + || (scheme.equals("http") && port == 80); + return scheme + "://" + uri.getHost().toLowerCase(Locale.ROOT) + + (defaultPort ? "" : ":" + port); + } + public Map getFormSchema() { return formSchema; } @@ -91,6 +147,14 @@ public String getLastPublishDate() { return this.lastPublishDate; } + public List getIntegrationOrigins() { + return integrationOrigins; + } + + public void setIntegrationOrigins(List integrationOrigins) { + this.integrationOrigins = integrationOrigins; + } + public List getBenefits() { return benefits; } diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/model/dto/screener/EditScreenerRequest.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/model/dto/screener/EditScreenerRequest.java index bfadb2de..7708cf38 100644 --- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/model/dto/screener/EditScreenerRequest.java +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/model/dto/screener/EditScreenerRequest.java @@ -1,6 +1,11 @@ package org.codeforphilly.bdt.builder.model.dto.screener; +import java.util.List; import org.codeforphilly.bdt.builder.api.validation.AtLeastOneProvided; -@AtLeastOneProvided(fields = {"screenerName"}) -public record EditScreenerRequest(String screenerName) {} +@AtLeastOneProvided(fields = {"screenerName", "integrationOrigins"}) +public record EditScreenerRequest(String screenerName, List integrationOrigins) { + public EditScreenerRequest(String screenerName) { + this(screenerName, null); + } +} diff --git a/builder-api/src/test/java/org/codeforphilly/bdt/builder/controller/ScreenerResourceTest.java b/builder-api/src/test/java/org/codeforphilly/bdt/builder/controller/ScreenerResourceTest.java index 32bfd404..40541c6b 100644 --- a/builder-api/src/test/java/org/codeforphilly/bdt/builder/controller/ScreenerResourceTest.java +++ b/builder-api/src/test/java/org/codeforphilly/bdt/builder/controller/ScreenerResourceTest.java @@ -16,10 +16,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.Optional; import java.util.List; +import java.util.Map; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -67,6 +70,34 @@ void unchangedNameSavesWithoutTheUniquenessCheck() throws Exception { verify(screenerRepository, never()).renameWorkingScreener(any()); } + @Test + void integrationOriginsAreSavedAsBrowserOrigins() throws Exception { + var request = new EditScreenerRequest(null, List.of( + " HTTPS://CRM.Example:443/ ", "http://localhost:4174", "", "https://crm.example")); + try (var response = resource.updateScreener(identity, "screener-1", request)) { + assertEquals(200, response.getStatus()); + } + assertEquals(List.of("https://crm.example", "http://localhost:4174"), + screener.getIntegrationOrigins()); + verify(screenerRepository).updateWorkingScreener(screener); + } + + @Test + void invalidIntegrationOriginsAreRejected() throws Exception { + for (String origin : List.of( + "crm.example", "https://crm.example/path", "https://crm.example?x=1", + "https://user@crm.example", "javascript:alert(1)", "*", "null")) { + var request = new EditScreenerRequest(null, List.of(origin)); + try (var response = resource.updateScreener(identity, "screener-1", request)) { + assertEquals(400, response.getStatus(), origin); + assertTrue(((Map) response.getEntity()).get("error").toString() + .contains("CRM origin"), origin); + } + } + assertNull(screener.getIntegrationOrigins()); + verify(screenerRepository, never()).updateWorkingScreener(any()); + } + @Test void draftChecksOwnershipBeforeReadingBenefitsOrCallingGemini() throws Exception { screener.setOwnerId("someone-else"); diff --git a/builder-frontend/public/integrations/host.js b/builder-frontend/public/integrations/host.js index e61caaac..91094259 100644 --- a/builder-frontend/public/integrations/host.js +++ b/builder-frontend/public/integrations/host.js @@ -25,8 +25,9 @@ function randomId() { * onError receives `{ code }`: INITIALIZATION_FAILED, CONNECTION_TIMEOUT (no * screener initialized within connectTimeoutMs), ORIGIN_MISMATCH (the screener * answered from another origin, such as after a redirect; includes `origin`), - * SCREENER_UNAVAILABLE (the published screener couldn't be loaded), or - * EVALUATION_FAILED. + * SCREENER_UNAVAILABLE (the published screener couldn't be loaded), + * ORIGIN_NOT_ALLOWED (the screener's builder hasn't allowed this host's + * origin), or EVALUATION_FAILED. */ export function createCrmIntegration({ screenerUrl, @@ -37,7 +38,7 @@ export function createCrmIntegration({ onResult, onError = () => {}, onInitialized = () => {}, - connectTimeoutMs = 15_000, + connectTimeoutMs = 30_000, hostWindow = window, }) { const url = new URL(screenerUrl); @@ -147,7 +148,7 @@ export function createCrmIntegration({ onResult(message); } else if ( message.type === "error" && - message.code === "SCREENER_UNAVAILABLE" + ["SCREENER_UNAVAILABLE", "ORIGIN_NOT_ALLOWED"].includes(message.code) ) { clearTimeout(connectTimer); onError(message); diff --git a/builder-frontend/src/api/screener.ts b/builder-frontend/src/api/screener.ts index 1daaf0e2..6ed32356 100644 --- a/builder-frontend/src/api/screener.ts +++ b/builder-frontend/src/api/screener.ts @@ -65,8 +65,8 @@ export const createNewScreener = async (request: { export const updateScreener = async ( screenerId: string, - request: { screenerName: string }, -) => { + request: { screenerName?: string; integrationOrigins?: string[] }, +): Promise<{ integrationOrigins?: string[] }> => { const url = new URL(`${apiUrl}/screener/${screenerId}`); try { @@ -75,6 +75,7 @@ export const updateScreener = async ( if (!response.ok) { throw await apiErrorFrom(response, "Could not update the screener"); } + return await response.json(); } catch (error) { console.error("Error updating screener:", error); throw error; diff --git a/builder-frontend/src/components/screener/Screener.test.tsx b/builder-frontend/src/components/screener/Screener.test.tsx index 388036d6..371795e5 100644 --- a/builder-frontend/src/components/screener/Screener.test.tsx +++ b/builder-frontend/src/components/screener/Screener.test.tsx @@ -19,7 +19,8 @@ const integration = vi.hoisted(() => ({ dispose: vi.fn(), result: vi.fn(), error: vi.fn(), - unavailable: vi.fn(), + fail: vi.fn(), + origin: "https://crm.example", })); vi.mock("@solidjs/router", () => ({ @@ -29,6 +30,7 @@ vi.mock("@/api/publishedScreener", () => ({ fetchPublishedScreener: vi.fn(async () => ({ screenerName: "Test", formSchema: {}, + integrationOrigins: ["https://crm.example"], })), evaluatePublishedScreener: vi.fn(), })); @@ -144,8 +146,9 @@ describe("published screener integration lifecycle", () => { new Error("Fetch failed with status: 404"), ); dispose = render(() => , container); - await vi.waitFor(() => - expect(integration.unavailable).toHaveBeenCalledOnce(), + await vi.waitFor(() => expect(integration.start).toHaveBeenCalledOnce()); + expect(integration.fail).toHaveBeenCalledExactlyOnceWith( + "SCREENER_UNAVAILABLE", ); expect(container.textContent).toContain("couldn’t be loaded"); expect(container.textContent).not.toContain("Waiting for information"); @@ -154,6 +157,47 @@ describe("published screener integration lifecycle", () => { expect(container.querySelector("button")).toBeNull(); }); + it("starts the bridge only after the screener loads", async () => { + let load!: (screener: any) => void; + vi.mocked(fetchPublishedScreener).mockReturnValueOnce( + new Promise((resolve) => (load = resolve)), + ); + dispose = render(() => , container); + await Promise.resolve(); + expect(integration.start).not.toHaveBeenCalled(); + load({ + screenerName: "Test", + formSchema: {}, + integrationOrigins: ["https://crm.example"], + }); + await vi.waitFor(() => expect(integration.start).toHaveBeenCalledOnce()); + expect(integration.fail).not.toHaveBeenCalled(); + }); + + it("refuses a host whose origin the screener doesn't allow", async () => { + vi.mocked(fetchPublishedScreener).mockResolvedValueOnce({ + screenerName: "Test", + formSchema: {}, + integrationOrigins: ["https://other-crm.example"], + }); + vi.mocked(evaluatePublishedScreener).mockResolvedValue(result("TRUE")); + dispose = render(() => , container); + await vi.waitFor(() => expect(integration.start).toHaveBeenCalledOnce()); + expect(integration.fail).toHaveBeenCalledExactlyOnceWith( + "ORIGIN_NOT_ALLOWED", + ); + await vi.waitFor(() => + expect(container.querySelector("button")).not.toBeNull(), + ); + expect(container.textContent).toContain( + "isn’t set up to send results to https://crm.example", + ); + expect(evaluatePublishedScreener).not.toHaveBeenCalled(); + integration.disconnect!("timeout"); + await Promise.resolve(); + expect(container.textContent).toContain("isn’t set up"); + }); + it("offers the form unconnected when no host initializes it", async () => { vi.mocked(evaluatePublishedScreener).mockResolvedValue(result("TRUE")); dispose = render(() => , container); diff --git a/builder-frontend/src/components/screener/Screener.tsx b/builder-frontend/src/components/screener/Screener.tsx index a05cb6c2..3b9ddda7 100644 --- a/builder-frontend/src/components/screener/Screener.tsx +++ b/builder-frontend/src/components/screener/Screener.tsx @@ -4,7 +4,6 @@ import { createMemo, createEffect, Show, - onMount, onCleanup, } from "solid-js"; import { useParams } from "@solidjs/router"; @@ -37,7 +36,7 @@ export default function Screener() { const [screenerResult, setScreenerResult] = createSignal(); const [formData, setFormData] = createSignal({}); const [disconnectReason, setDisconnectReason] = createSignal< - "timeout" | "closed" + "timeout" | "closed" | "notAllowed" >(); const bridge = createScreeningBridge(params.publishedScreenerId, { initialize: (data) => { @@ -46,15 +45,28 @@ export default function Screener() { }, // Keep the form usable, but make clear results no longer reach the host. onDisconnect: (reason) => { - setDisconnectReason(reason); + if (!disconnectReason()) setDisconnectReason(reason); setIntegrationReady(true); }, }); const [integrationReady, setIntegrationReady] = createSignal(!bridge); let evaluationSequence = 0; - onMount(() => bridge?.start()); + // Start once the screener loads, so the host's origin can be checked against + // the origins its builder allowed. Only an allowed host gets answers. + let bridgeStarted = false; createEffect(() => { - if (screener.error) bridge?.unavailable(); + if (!bridge || bridgeStarted) return; + if (screener.error) { + bridge.fail("SCREENER_UNAVAILABLE"); + } else if (screener.state !== "ready") { + return; + } else if (!screener()?.integrationOrigins?.includes(bridge.origin)) { + bridge.fail("ORIGIN_NOT_ALLOWED"); + setDisconnectReason("notAllowed"); + setIntegrationReady(true); + } + bridgeStarted = true; + bridge.start(); }); onCleanup(() => { evaluationSequence++; @@ -114,10 +126,15 @@ export default function Screener() { {(reason) => ( )} diff --git a/builder-frontend/src/components/screenerEditor/IntegrationOrigins.test.tsx b/builder-frontend/src/components/screenerEditor/IntegrationOrigins.test.tsx new file mode 100644 index 00000000..e8fbd6e7 --- /dev/null +++ b/builder-frontend/src/components/screenerEditor/IntegrationOrigins.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { render } from "solid-js/web"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import IntegrationOrigins from "./IntegrationOrigins"; +import { updateScreener } from "@/api/screener"; + +vi.mock("@/api/screener", () => ({ updateScreener: vi.fn() })); + +describe("IntegrationOrigins", () => { + let dispose: (() => void) | undefined; + afterEach(() => { + dispose?.(); + document.body.innerHTML = ""; + }); + + function setup(origins?: string[]) { + const onSaved = vi.fn(); + const container = document.body.appendChild(document.createElement("div")); + dispose = render( + () => ( + origins} + onSaved={onSaved} + /> + ), + container, + ); + const textarea = container.querySelector("textarea")!; + const save = () => + Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent === "Save CRM origins")! + .click(); + return { container, textarea, save, onSaved }; + } + + it("saves one origin per line and shows the saved origins", async () => { + vi.mocked(updateScreener).mockResolvedValueOnce({ + integrationOrigins: ["https://crm.example"], + }); + const s = setup(["https://old.example"]); + expect(s.textarea.value).toBe("https://old.example"); + s.textarea.value = "HTTPS://CRM.example/\n"; + s.textarea.dispatchEvent(new Event("input", { bubbles: true })); + s.save(); + await vi.waitFor(() => + expect(s.onSaved).toHaveBeenCalledWith(["https://crm.example"]), + ); + expect(updateScreener).toHaveBeenCalledWith("screener-1", { + integrationOrigins: ["HTTPS://CRM.example/", ""], + }); + expect(s.textarea.value).toBe("https://crm.example"); + expect(s.container.textContent).toContain("Publish the screener to apply"); + }); + + it("shows the server's validation error", async () => { + vi.mocked(updateScreener).mockRejectedValueOnce( + new Error("Enter each CRM origin as https://host or https://host:port"), + ); + const s = setup(); + s.save(); + await vi.waitFor(() => + expect( + s.container.querySelector('[role="alert"]')?.textContent, + ).toContain("Enter each CRM origin"), + ); + expect(s.onSaved).not.toHaveBeenCalled(); + }); +}); diff --git a/builder-frontend/src/components/screenerEditor/IntegrationOrigins.tsx b/builder-frontend/src/components/screenerEditor/IntegrationOrigins.tsx new file mode 100644 index 00000000..a6b11945 --- /dev/null +++ b/builder-frontend/src/components/screenerEditor/IntegrationOrigins.tsx @@ -0,0 +1,78 @@ +import { Accessor, createSignal, Show } from "solid-js"; + +import { updateScreener } from "@/api/screener"; +import { Button } from "@/components/shared/Button"; + +/** CRM origins allowed to prefill the published screener and receive its results. */ +export default function IntegrationOrigins(props: { + screenerId: string; + origins: Accessor; + onSaved: (origins: string[]) => void; +}) { + const [text, setText] = createSignal((props.origins() ?? []).join("\n")); + const [saving, setSaving] = createSignal(false); + const [status, setStatus] = createSignal<{ error: boolean; text: string }>(); + + const save = async () => { + setSaving(true); + setStatus(); + try { + const saved = await updateScreener(props.screenerId, { + integrationOrigins: text().split("\n"), + }); + const origins = saved.integrationOrigins ?? []; + setText(origins.join("\n")); + setStatus({ + error: false, + text: "Saved. Publish the screener to apply these origins.", + }); + props.onSaved(origins); + } catch (error) { + setStatus({ + error: true, + text: + error instanceof Error + ? error.message + : "Could not save the CRM origins", + }); + } finally { + setSaving(false); + } + }; + + return ( +
    + +

    + CRMs at these origins can prefill this screener and receive its results. + Enter one per line, such as https://crm.example.org. Leave it empty to + turn off CRM integrations. +

    +