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 new file mode 100644 index 00000000..91094259 --- /dev/null +++ b/builder-frontend/public/integrations/host.js @@ -0,0 +1,179 @@ +// 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"), + ).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, + * 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`), + * 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, + getTargetWindow, + initialData, + requestId = randomId(), + serializeMessages = false, + onResult, + onError = () => {}, + onInitialized = () => {}, + connectTimeoutMs = 30_000, + hostWindow = window, +}) { + const url = new URL(screenerUrl); + // 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"); + } + 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; + let failedSessionId; + let reportedOriginMismatch = false; + 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; + 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 (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") { + // 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; + } + try { + send(target, "initialize", { inputData: initialData }); + } catch (error) { + if (failedSessionId === sessionId) return; + failedSessionId = sessionId; + 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; + clearTimeout(connectTimer); + 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 ( + message.type === "error" && + ["SCREENER_UNAVAILABLE", "ORIGIN_NOT_ALLOWED"].includes(message.code) + ) { + clearTimeout(connectTimer); + onError(message); + } else if ( + initialized && + message.type === "error" && + message.code === "EVALUATION_FAILED" + ) { + onError(message); + } + }; + hostWindow.addEventListener("message", receive); + return { + url: url.href, + 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/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/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 bbf370fb..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,18 +22,24 @@ function FormRenderer({ formData, hiddenQuestionPaths, submitForm, + evaluateInitialData = false, onDataChange, }: { schema: { [key: string]: any }; formData: Accessor; hiddenQuestionPaths: Accessor; submitForm: (data: any) => 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; @@ -55,16 +66,22 @@ function FormRenderer({ currentData = cloneDeep(formData()); const debouncedSubmit = debounce((data) => { - submitForm(normalizeArrayFieldData(schema, data)); + submitForm(evaluationData(data)); }, 1000); form .importSchema(hideQuestions(schema, hiddenQuestionPaths()), formData()) .then(() => { + currentData = cloneDeep(form?._getState().data || formData()); + if (evaluateInitialData) { + 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/components/screener/Screener.test.tsx b/builder-frontend/src/components/screener/Screener.test.tsx new file mode 100644 index 00000000..2f819619 --- /dev/null +++ b/builder-frontend/src/components/screener/Screener.test.tsx @@ -0,0 +1,260 @@ +// @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, + fetchPublishedScreener, +} from "@/api/publishedScreener"; +import type { ScreenerResult } from "@/types"; + +const integration = vi.hoisted(() => ({ + enabled: true, + initialize: undefined as + | undefined + | ((data: Record) => void), + disconnect: undefined as undefined | ((reason: string) => void), + start: vi.fn(), + dispose: vi.fn(), + result: vi.fn(), + error: vi.fn(), + fail: vi.fn(), + origin: "https://crm.example", +})); + +vi.mock("@solidjs/router", () => ({ + useParams: () => ({ publishedScreenerId: "published-id" }), +})); +vi.mock("@/api/publishedScreener", () => ({ + fetchPublishedScreener: vi.fn(async () => ({ + screenerName: "Test", + formSchema: {}, + integrationOrigins: ["https://crm.example"], + })), + evaluatePublishedScreener: vi.fn(), +})); +vi.mock("@/integrations/screenerBridge", () => ({ + createScreeningBridge: ( + _: string, + callbacks: { + initialize: () => void; + onDisconnect: (reason: string) => void; + }, + ) => { + integration.initialize = callbacks.initialize; + integration.disconnect = callbacks.onDisconnect; + 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("does not send an old evaluation to the CRM after an edit awaiting submission", async () => { + const old = deferred(); + vi.mocked(evaluatePublishedScreener).mockReturnValueOnce(old.promise); + dispose = render(() => , container); + integration.initialize!(initialData); + await vi.waitFor(() => + expect(evaluatePublishedScreener).toHaveBeenCalledOnce(), + ); + container.querySelectorAll("button")[1].click(); + old.resolve(result("TRUE")); + await old.promise; + expect(integration.result).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain("Eligible"); + }); + + 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("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.start).toHaveBeenCalledOnce()); + expect(integration.fail).toHaveBeenCalledExactlyOnceWith( + "SCREENER_UNAVAILABLE", + ); + 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("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); + await vi.waitFor(() => expect(integration.start).toHaveBeenCalledOnce()); + integration.disconnect!("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 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")); + 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..3b9ddda7 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, + createEffect, + Show, + 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,47 @@ export default function Screener() { ); const [screenerResult, setScreenerResult] = createSignal(); const [formData, setFormData] = createSignal({}); + const [disconnectReason, setDisconnectReason] = createSignal< + "timeout" | "closed" | "notAllowed" + >(); + const bridge = createScreeningBridge(params.publishedScreenerId, { + initialize: (data) => { + setFormData(data); + setIntegrationReady(true); + }, + // Keep the form usable, but make clear results no longer reach the host. + onDisconnect: (reason) => { + if (!disconnectReason()) setDisconnectReason(reason); + setIntegrationReady(true); + }, + }); + const [integrationReady, setIntegrationReady] = createSignal(!bridge); + let evaluationSequence = 0; + // 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 (!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++; + 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 +89,7 @@ export default function Screener() { ); const submitForm = async (data: any) => { - const version = ++evaluationVersion; + const sequence = ++evaluationSequence; setResultsStale(true); try { setFormData(data); @@ -53,13 +97,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 +113,32 @@ export default function Screener() { return (
{screener.loading && } - {screener() && ( + + + + +

+ Waiting for information from the connected application… +

+
+ + {(reason) => ( + + )} + + {!screener.error && screener() && integrationReady() && (
{ - evaluationVersion++; + evaluationSequence++; setResultsStale(true); }} /> 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. +

+