mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 11:29:31 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcaeefc32e |
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user