Compare commits

...

1 Commits

Author SHA1 Message Date
Johannes fcaeefc32e feat: support URL userId lookup for link surveys
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 11:07:39 +02:00
8 changed files with 191 additions and 22 deletions
@@ -0,0 +1,60 @@
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContactByUserId } from "./contact";
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn: Function) => fn),
};
});
const workspaceId = "test-workspace-id";
const userId = "test-user-id";
const contact = {
id: "test-contact-id",
createdAt: new Date(),
updatedAt: new Date(),
workspaceId,
};
describe("getContactByUserId", () => {
test("returns the first contact whose userId attribute exactly matches in the workspace", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: contact.id });
const result = await getContactByUserId(workspaceId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
workspaceId,
},
value: userId,
},
},
},
select: { id: true },
});
expect(result).toEqual({ id: contact.id });
});
test("returns null when no contact matches", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await getContactByUserId(workspaceId, userId);
expect(result).toBeNull();
});
});
@@ -26,6 +26,7 @@ interface PinScreenProps {
isEmbed: boolean;
isPreview: boolean;
contactId?: string;
canReadUserIdFromUrl?: boolean;
recaptchaSiteKey?: string;
isSpamProtectionEnabled?: boolean;
responseCount?: number;
@@ -48,6 +49,7 @@ export const PinScreen = (props: PinScreenProps) => {
isEmbed,
isPreview,
contactId,
canReadUserIdFromUrl = false,
recaptchaSiteKey,
isSpamProtectionEnabled = false,
responseCount,
@@ -130,6 +132,7 @@ export const PinScreen = (props: PinScreenProps) => {
singleUseId={singleUseId}
singleUseResponseId={singleUseResponse?.id}
contactId={contactId}
canReadUserIdFromUrl={canReadUserIdFromUrl}
recaptchaSiteKey={recaptchaSiteKey}
isSpamProtectionEnabled={isSpamProtectionEnabled}
isPreview={isPreview}
@@ -11,6 +11,7 @@ import { CustomScriptsInjector } from "@/modules/survey/link/components/custom-s
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { OfflineAlert } from "@/modules/survey/link/components/offline-alert";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { getUserIdFromSearchParams } from "@/modules/survey/link/lib/user-id";
import { isRTLLanguage } from "@/modules/survey/link/lib/utils";
import { SurveyInline } from "@/modules/ui/components/survey";
@@ -25,6 +26,7 @@ interface SurveyClientWrapperProps {
singleUseId?: string;
singleUseResponseId?: string;
contactId?: string;
canReadUserIdFromUrl?: boolean;
recaptchaSiteKey?: string;
isSpamProtectionEnabled: boolean;
isPreview: boolean;
@@ -49,6 +51,7 @@ export const SurveyClientWrapper = ({
singleUseId,
singleUseResponseId,
contactId,
canReadUserIdFromUrl = false,
recaptchaSiteKey,
isSpamProtectionEnabled,
isPreview,
@@ -61,6 +64,7 @@ export const SurveyClientWrapper = ({
const searchParams = useSearchParams();
const skipPrefilled = searchParams.get("skipPrefilled") === "true";
const offlineSupport = searchParams.get("offlineSupport") === "true";
const userId = canReadUserIdFromUrl ? getUserIdFromSearchParams(searchParams) : undefined;
const elements = useMemo(() => getElementsFromBlocks(survey.blocks), [survey.blocks]);
const startAt = searchParams.get("startAt");
@@ -194,6 +198,7 @@ export const SurveyClientWrapper = ({
singleUseResponseId={singleUseResponseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
userId={userId}
recaptchaSiteKey={recaptchaSiteKey}
isSpamProtectionEnabled={isSpamProtectionEnabled}
offlineSupport={offlineSupport}
@@ -12,26 +12,31 @@ import {
TERMS_URL,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyClientWrapper } from "@/modules/survey/link/components/survey-client-wrapper";
import { SurveyCompletedMessage } from "@/modules/survey/link/components/survey-completed-message";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { hasUserIdSearchParam } from "@/modules/survey/link/lib/user-id";
import { TWorkspaceContextForLinkSurvey } from "@/modules/survey/link/lib/workspace";
type TLinkSurveySearchParams = {
verify?: string;
lang?: string;
embed?: string;
preview?: string;
suId?: string;
} & Record<string, string | string[] | undefined>;
interface SurveyRendererProps {
survey: TSurvey;
searchParams: {
verify?: string;
lang?: string;
embed?: string;
preview?: string;
suId?: string;
};
searchParams: TLinkSurveySearchParams;
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
contactId?: string;
allowUrlUserIdLookup?: boolean;
isPreview: boolean;
// New props - pre-fetched in parent
workspaceContext: TWorkspaceContextForLinkSurvey;
@@ -46,7 +51,7 @@ interface SurveyRendererProps {
* database queries. The parent (page.tsx) fetches data in parallel stages
* to minimize latency for users geographically distant from servers.
*
* @param environmentContext - Pre-fetched workspace and organization data
* @param workspaceContext - Pre-fetched workspace and organization data
* @param locale - User's locale from Accept-Language header
* @param responseCount - Conditionally fetched if showResponseCount is enabled
*/
@@ -56,6 +61,7 @@ export const renderSurvey = async ({
singleUseId,
singleUseResponse,
contactId,
allowUrlUserIdLookup = false,
isPreview,
workspaceContext,
locale,
@@ -131,6 +137,10 @@ export const renderSurvey = async ({
const styling = computeStyling(workspace.styling, survey.styling);
const languageCode = getLanguageCode(langParam, survey);
const publicDomain = getPublicDomain();
const canReadUserIdFromUrl =
allowUrlUserIdLookup && !contactId && hasUserIdSearchParam(searchParams)
? await getIsContactsEnabled(workspaceContext.organizationId)
: false;
// Handle PIN-protected surveys
if (survey.pin) {
@@ -151,6 +161,7 @@ export const renderSurvey = async ({
isEmbed={isEmbed}
isPreview={isPreview}
contactId={contactId}
canReadUserIdFromUrl={canReadUserIdFromUrl}
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
isSpamProtectionEnabled={isSpamProtectionEnabled}
responseCount={responseCount}
@@ -171,6 +182,7 @@ export const renderSurvey = async ({
singleUseId={singleUseId}
singleUseResponseId={singleUseResponse?.id}
contactId={contactId}
canReadUserIdFromUrl={canReadUserIdFromUrl}
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
isSpamProtectionEnabled={isSpamProtectionEnabled}
isPreview={isPreview}
@@ -17,17 +17,19 @@ import {
import { getWorkspaceContextForLinkSurvey } from "@/modules/survey/link/lib/workspace";
import { getWorkspaceById } from "@/modules/survey/link/lib/workspace";
type TContactSurveyPageSearchParams = {
suId?: string;
verify?: string;
lang?: string;
embed?: string;
preview?: string;
} & Record<string, string | string[] | undefined>;
interface ContactSurveyPageProps {
params: Promise<{
jwt: string;
}>;
searchParams: Promise<{
suId?: string;
verify?: string;
lang?: string;
embed?: string;
preview?: string;
}>;
searchParams: Promise<TContactSurveyPageSearchParams>;
}
export const generateMetadata = async (props: ContactSurveyPageProps): Promise<Metadata> => {
@@ -0,0 +1,46 @@
import { describe, expect, test } from "vitest";
import { getUserIdFromSearchParams, hasUserIdSearchParam } from "./user-id";
describe("getUserIdFromSearchParams", () => {
test("returns the first case-insensitive userId value in URL order", () => {
const searchParams = new URLSearchParams("foo=bar&UserID=first&userId=second");
expect(getUserIdFromSearchParams(searchParams)).toBe("first");
});
test("keeps the exact decoded value without trimming or case normalization", () => {
const searchParams = new URLSearchParams("userId=%20Test%40Example.com%20");
expect(getUserIdFromSearchParams(searchParams)).toBe(" Test@Example.com ");
});
test("ignores empty values", () => {
const searchParams = new URLSearchParams("userId=");
expect(getUserIdFromSearchParams(searchParams)).toBeUndefined();
});
test("returns undefined when no userId parameter exists", () => {
const searchParams = new URLSearchParams("source=email");
expect(getUserIdFromSearchParams(searchParams)).toBeUndefined();
});
test("supports plain Next.js searchParams objects", () => {
expect(getUserIdFromSearchParams({ source: "email", UserID: "from-object" })).toBe("from-object");
});
test("supports array values in plain searchParams objects", () => {
expect(getUserIdFromSearchParams({ userId: ["first", "second"] })).toBe("first");
});
});
describe("hasUserIdSearchParam", () => {
test("returns true when a non-empty userId parameter exists", () => {
expect(hasUserIdSearchParam(new URLSearchParams("userId=abc"))).toBe(true);
});
test("returns false when the userId parameter is empty", () => {
expect(hasUserIdSearchParam(new URLSearchParams("userId="))).toBe(false);
});
});
@@ -0,0 +1,38 @@
type TSearchParamValue = string | string[] | undefined;
type TSearchParamsWithEntries = Pick<URLSearchParams, "entries">;
type TSearchParamsRecord = Record<string, TSearchParamValue>;
type TUserIdSearchParams = TSearchParamsWithEntries | TSearchParamsRecord;
function* getSearchParamEntries(searchParams: TUserIdSearchParams): Generator<[string, string]> {
if ("entries" in searchParams && typeof searchParams.entries === "function") {
yield* searchParams.entries();
return;
}
for (const [key, value] of Object.entries(searchParams)) {
if (Array.isArray(value)) {
for (const arrayValue of value) {
yield [key, arrayValue];
}
} else if (value !== undefined) {
yield [key, value];
}
}
}
export const getUserIdFromSearchParams = (searchParams: TUserIdSearchParams): string | undefined => {
for (const [key, value] of getSearchParamEntries(searchParams)) {
if (key.toLowerCase() === "userid") {
return value === "" ? undefined : value;
}
}
return undefined;
};
export const hasUserIdSearchParam = (searchParams: TUserIdSearchParams): boolean => {
return getUserIdFromSearchParams(searchParams) !== undefined;
};
+10 -7
View File
@@ -12,17 +12,19 @@ import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
import { getWorkspaceContextForLinkSurvey } from "@/modules/survey/link/lib/workspace";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
type TLinkSurveyPageSearchParams = {
suId?: string;
verify?: string;
lang?: string;
embed?: string;
preview?: string;
} & Record<string, string | string[] | undefined>;
interface LinkSurveyPageProps {
params: Promise<{
surveyId: string;
}>;
searchParams: Promise<{
suId?: string;
verify?: string;
lang?: string;
embed?: string;
preview?: string;
}>;
searchParams: Promise<TLinkSurveyPageSearchParams>;
}
export const generateMetadata = async (props: LinkSurveyPageProps): Promise<Metadata> => {
@@ -121,6 +123,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
searchParams,
singleUseId,
singleUseResponse: singleUseResponse ?? undefined,
allowUrlUserIdLookup: true,
isPreview,
workspaceContext,
locale,