diff --git a/workbench/_web/src/app/workbench/[workspaceId]/patch-lens/[chartId]/components/PatchLensArea.tsx b/workbench/_web/src/app/workbench/[workspaceId]/patch-lens/[chartId]/components/PatchLensArea.tsx index 280c5a35..5b1a4590 100644 --- a/workbench/_web/src/app/workbench/[workspaceId]/patch-lens/[chartId]/components/PatchLensArea.tsx +++ b/workbench/_web/src/app/workbench/[workspaceId]/patch-lens/[chartId]/components/PatchLensArea.tsx @@ -23,6 +23,9 @@ import { TutorialActivityPanel } from "./tutorial/TutorialActivityPanel"; import { getModels } from "@/lib/api/modelsApi"; import { useWorkspaceWorkshop } from "@/lib/api/workshopApi"; import { useWorkspaceTutorial } from "@/lib/api/tutorialContentApi"; +import { getWorkspaceById } from "@/lib/queries/workspaceQueries"; +import { withProlificPid } from "@/lib/prolific"; +import { queryKeys } from "@/lib/queryKeys"; import { useWorkspace } from "@/stores/useWorkspace"; import { encodeText } from "@/actions/tok"; import { TokenizerLoadError } from "@/actions/errors"; @@ -167,6 +170,17 @@ export default function PatchLensArea({ workspaceId as string, ); + // The workspace's captured Prolific identifiers (set on first arrival via the + // /w join link) — used to pass PROLIFIC_PID through to the post-survey so the + // response can be matched back to the study. Same cache key as elsewhere, so + // this is usually a hit rather than a fresh fetch. + const { data: workspace } = useQuery({ + queryKey: queryKeys.workspaces.workspace(workspaceId as string), + queryFn: () => getWorkspaceById(workspaceId as string), + enabled: !!workspaceId, + }); + const surveyUrl = withProlificPid(workshop?.surveyUrl, workspace?.prolific?.prolificPid); + // Default to Llama-3.1-8B once when models load, rather than leaving the // workspace default at index 0 (the 70B, 80 layers). Guarded so a later // manual model choice is not overridden. Waits for the workshop lookup so @@ -715,7 +729,7 @@ export default function PatchLensArea({ topToken={runTokens.top} secondToken={runTokens.second} runUnitIdx={runTokens.unitIdx} - surveyUrl={workshop?.surveyUrl} + surveyUrl={surveyUrl} completionThanks={workshop?.completionText} workshopMode={!!workshop} onSpotlight={setSpotlight} diff --git a/workbench/_web/src/lib/__tests__/prolific.test.ts b/workbench/_web/src/lib/__tests__/prolific.test.ts new file mode 100644 index 00000000..4562a1de --- /dev/null +++ b/workbench/_web/src/lib/__tests__/prolific.test.ts @@ -0,0 +1,69 @@ +/** + * Unit tests for Prolific param parsing + survey-URL pass-through. + */ + +import { describe, it, expect } from "bun:test"; +import { parseProlificParams, withProlificPid } from "@/lib/prolific"; + +describe("parseProlificParams", () => { + it("reads canonical UPPER_CASE keys", () => { + expect( + parseProlificParams({ PROLIFIC_PID: "p1", STUDY_ID: "s1", SESSION_ID: "x1" }), + ).toEqual({ prolificPid: "p1", studyId: "s1", sessionId: "x1" }); + }); + + it("accepts lower_case defensively and trims", () => { + expect(parseProlificParams({ prolific_pid: " p1 " })).toEqual({ prolificPid: "p1" }); + }); + + it("collapses repeated params to the first value", () => { + expect(parseProlificParams({ PROLIFIC_PID: ["p1", "p2"] })).toEqual({ prolificPid: "p1" }); + }); + + it("returns null when nothing is present (store nothing, not {})", () => { + expect(parseProlificParams({})).toBeNull(); + expect(parseProlificParams({ PROLIFIC_PID: " " })).toBeNull(); + }); +}); + +describe("withProlificPid", () => { + it("appends PROLIFIC_PID to a bare survey URL", () => { + expect(withProlificPid("https://survey.qualtrics.com/jfe/form/SV_x", "p1")).toBe( + "https://survey.qualtrics.com/jfe/form/SV_x?PROLIFIC_PID=p1", + ); + }); + + it("merges with an existing query string rather than clobbering it", () => { + expect(withProlificPid("https://survey.example.com/s?foo=bar", "p1")).toBe( + "https://survey.example.com/s?foo=bar&PROLIFIC_PID=p1", + ); + }); + + it("overwrites a stale PROLIFIC_PID instead of duplicating it", () => { + expect(withProlificPid("https://survey.example.com/s?PROLIFIC_PID=old", "new")).toBe( + "https://survey.example.com/s?PROLIFIC_PID=new", + ); + }); + + it("URL-encodes the pid value", () => { + expect(withProlificPid("https://survey.example.com/s", "a b&c")).toBe( + "https://survey.example.com/s?PROLIFIC_PID=a+b%26c", + ); + }); + + it("passes the URL through untouched when no pid is set", () => { + const url = "https://survey.example.com/s"; + expect(withProlificPid(url, undefined)).toBe(url); + expect(withProlificPid(url, "")).toBe(url); + }); + + it("passes through when there is no survey URL", () => { + expect(withProlificPid(undefined, "p1")).toBeUndefined(); + expect(withProlificPid("", "p1")).toBe(""); + }); + + it("returns a non-absolute/malformed URL unchanged", () => { + expect(withProlificPid("/relative/survey", "p1")).toBe("/relative/survey"); + expect(withProlificPid("not a url", "p1")).toBe("not a url"); + }); +}); diff --git a/workbench/_web/src/lib/prolific.ts b/workbench/_web/src/lib/prolific.ts index 4a2c961c..9794b1f7 100644 --- a/workbench/_web/src/lib/prolific.ts +++ b/workbench/_web/src/lib/prolific.ts @@ -39,3 +39,26 @@ export function parseProlificParams(searchParams: RawSearchParams): ProlificPara return Object.keys(params).length > 0 ? params : null; } + +/** + * Appends the participant's Prolific PID to a survey URL so the post-survey + * (Qualtrics) can tie the response back to the study — the final link becomes + * `…?PROLIFIC_PID=` (merged with any existing query string). Prolific and + * Qualtrics expect the literal UPPER_CASE key. Returns the URL untouched when + * there's no PID (participant didn't arrive via Prolific), no URL, or the URL + * isn't absolute/parseable. + */ +export function withProlificPid( + surveyUrl: string | undefined, + prolificPid: string | undefined, +): string | undefined { + if (!surveyUrl || !prolificPid) return surveyUrl; + try { + const url = new URL(surveyUrl); + url.searchParams.set("PROLIFIC_PID", prolificPid); + return url.toString(); + } catch { + // Non-absolute or malformed survey URL — pass through unchanged. + return surveyUrl; + } +}