Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
69 changes: 69 additions & 0 deletions workbench/_web/src/lib/__tests__/prolific.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
23 changes: 23 additions & 0 deletions workbench/_web/src/lib/prolific.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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;
}
}
Loading