feat: personalized survey links (#4870)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
Matti Nannt
2025-03-19 08:30:39 +01:00
committed by GitHub
parent 3b126291a6
commit 214d18616f
28 changed files with 1275 additions and 146 deletions

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route";
export { GET };

View File

@@ -0,0 +1,4 @@
import { ContactSurveyPage, generateMetadata } from "@/modules/survey/link/contact-survey/page";
export { generateMetadata };
export default ContactSurveyPage;

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContact } from "./contacts";
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findUnique: vi.fn(),
},
},
}));
describe("getContact", () => {
const mockContactId = "cm8fj8ry6000008l5daam88nc";
const mockEnvironmentId = "cm8fj8xt3000108l5art7594h";
const mockContact = {
id: mockContactId,
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns contact when found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact);
const result = await getContact(mockContactId, mockEnvironmentId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: {
id: mockContactId,
environmentId: mockEnvironmentId,
},
select: {
id: true,
},
});
if (result.ok) {
expect(result.data).toEqual(mockContact);
}
});
test("returns null when contact not found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
const result = await getContact(mockContactId, mockEnvironmentId);
expect(prisma.contact.findUnique).toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toEqual({
details: [
{
field: "contact",
issue: "not found",
},
],
type: "not_found",
});
}
});
});

View File

@@ -0,0 +1,37 @@
import { contactCache } from "@/lib/cache/contact";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Contact } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContact = reactCache(async (contactId: string, environmentId: string) =>
cache(
async (): Promise<Result<Pick<Contact, "id">, ApiErrorResponseV2>> => {
try {
const contact = await prisma.contact.findUnique({
where: {
id: contactId,
environmentId,
},
select: {
id: true,
},
});
if (!contact) {
return err({ type: "not_found", details: [{ field: "contact", issue: "not found" }] });
}
return ok(contact);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "contact", issue: error.message }] });
}
},
[`contact-link-getContact-${contactId}-${environmentId}`],
{
tags: [contactCache.tag.byId(contactId), contactCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getResponse } from "./response";
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
findFirst: vi.fn(),
},
},
}));
describe("getResponse", () => {
const mockContactId = "cm8fj8xt3000108l5art7594h";
const mockSurveyId = "cm8fj9962000208l56jcu94i5";
const mockResponse = {
id: "cm8fj9gqp000308l5ab7y800j",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns response when found", async () => {
vi.mocked(prisma.response.findFirst).mockResolvedValue(mockResponse);
const result = await getResponse(mockContactId, mockSurveyId);
expect(prisma.response.findFirst).toHaveBeenCalledWith({
where: {
contactId: mockContactId,
surveyId: mockSurveyId,
},
select: {
id: true,
},
});
if (result.ok) {
expect(result.data).toEqual(mockResponse);
}
});
test("returns null when response not found", async () => {
vi.mocked(prisma.response.findFirst).mockResolvedValue(null);
const result = await getResponse(mockContactId, mockSurveyId);
expect(prisma.response.findFirst).toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toEqual({
details: [
{
field: "response",
issue: "not found",
},
],
type: "not_found",
});
}
});
});

View File

@@ -0,0 +1,37 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Response } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { responseCache } from "@formbricks/lib/response/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getResponse = reactCache(async (contactId: string, surveyId: string) =>
cache(
async (): Promise<Result<Pick<Response, "id">, ApiErrorResponseV2>> => {
try {
const response = await prisma.response.findFirst({
where: {
contactId,
surveyId,
},
select: {
id: true,
},
});
if (!response) {
return err({ type: "not_found", details: [{ field: "response", issue: "not found" }] });
}
return ok(response);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "response", issue: error.message }] });
}
},
[`contact-link-getResponse-${contactId}-${surveyId}`],
{
tags: [responseCache.tag.byId(contactId), responseCache.tag.bySurveyId(surveyId)],
}
)()
);

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getSurvey } from "./surveys";
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findUnique: vi.fn(),
},
},
}));
describe("getSurvey", () => {
const mockSurveyId = "cm8fj9psb000408l50e1x4c6f";
const mockSurvey = {
id: mockSurveyId,
type: "web",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns survey when found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: {
id: mockSurveyId,
},
select: {
id: true,
type: true,
},
});
if (result.ok) {
expect(result.data).toEqual(mockSurvey);
}
});
test("returns null when survey not found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toEqual({
details: [
{
field: "survey",
issue: "not found",
},
],
type: "not_found",
});
}
});
});

View File

@@ -0,0 +1,35 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Survey } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getSurvey = reactCache(async (surveyId: string) =>
cache(
async (): Promise<Result<Pick<Survey, "id" | "type">, ApiErrorResponseV2>> => {
try {
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
select: {
id: true,
type: true,
},
});
if (!survey) {
return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] });
}
return ok(survey);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] });
}
},
[`contact-link-getSurvey-${surveyId}`],
{
tags: [surveyCache.tag.byId(surveyId)],
}
)()
);

View File

@@ -0,0 +1,111 @@
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getContact } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts";
import { getResponse } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response";
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
const ZContactLinkParams = z.object({
surveyId: ZId,
contactId: ZId,
});
export const GET = async (
request: Request,
props: { params: Promise<{ surveyId: string; contactId: string }> }
) =>
authenticatedApiClient({
request,
externalParams: props.params,
schemas: {
params: ZContactLinkParams,
},
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentId(params.surveyId, false);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const surveyResult = await getSurvey(params.surveyId);
if (!surveyResult.ok) {
return handleApiError(request, surveyResult.error);
}
const survey = surveyResult.data;
if (!survey) {
return handleApiError(request, {
type: "not_found",
details: [{ field: "surveyId", issue: "Not found" }],
});
}
if (survey.type !== "link") {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "surveyId", issue: "Not a link survey" }],
});
}
// Check if contact exists and belongs to the environment
const contactResult = await getContact(params.contactId, environmentId);
if (!contactResult.ok) {
return handleApiError(request, contactResult.error);
}
const contact = contactResult.data;
if (!contact) {
return handleApiError(request, {
type: "not_found",
details: [{ field: "contactId", issue: "Not found" }],
});
}
// Check if contact has already responded to this survey
const existingResponseResult = await getResponse(params.contactId, params.surveyId);
if (existingResponseResult.ok) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactId", issue: "Already responded" }],
});
}
const surveyUrlResult = getContactSurveyLink(params.contactId, params.surveyId, 7);
if (!surveyUrlResult.ok) {
return handleApiError(request, surveyUrlResult.error);
}
return responses.successResponse({ data: { surveyUrl: surveyUrlResult.data } });
},
});

View File

@@ -0,0 +1,188 @@
import jwt from "jsonwebtoken";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ENCRYPTION_KEY, WEBAPP_URL } from "@formbricks/lib/constants";
import * as crypto from "@formbricks/lib/crypto";
import * as contactSurveyLink from "./contact-survey-link";
// Mock all modules needed (this gets hoisted to the top of the file)
vi.mock("jsonwebtoken", () => ({
default: {
sign: vi.fn(),
verify: vi.fn(),
},
}));
// Mock constants - MUST be a literal object without using variables
vi.mock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
WEBAPP_URL: "https://test.formbricks.com",
}));
vi.mock("@formbricks/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
symmetricDecrypt: vi.fn(),
}));
describe("Contact Survey Link", () => {
const mockContactId = "contact-123";
const mockSurveyId = "survey-456";
const mockToken = "mock.jwt.token";
const mockEncryptedContactId = "encrypted-contact-id";
const mockEncryptedSurveyId = "encrypted-survey-id";
beforeEach(() => {
vi.clearAllMocks();
// Setup default mocks
vi.mocked(crypto.symmetricEncrypt).mockImplementation((value) =>
value === mockContactId ? mockEncryptedContactId : mockEncryptedSurveyId
);
vi.mocked(crypto.symmetricDecrypt).mockImplementation((value) => {
if (value === mockEncryptedContactId) return mockContactId;
if (value === mockEncryptedSurveyId) return mockSurveyId;
return value;
});
vi.mocked(jwt.sign).mockReturnValue(mockToken as any);
vi.mocked(jwt.verify).mockReturnValue({
contactId: mockEncryptedContactId,
surveyId: mockEncryptedSurveyId,
} as any);
});
describe("getContactSurveyLink", () => {
it("creates a survey link with encrypted contact and survey IDs", () => {
const result = contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
// Verify encryption was called for both IDs
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockContactId, ENCRYPTION_KEY);
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockSurveyId, ENCRYPTION_KEY);
// Verify JWT sign was called with correct payload
expect(jwt.sign).toHaveBeenCalledWith(
{
contactId: mockEncryptedContactId,
surveyId: mockEncryptedSurveyId,
},
ENCRYPTION_KEY,
{ algorithm: "HS256" }
);
// Verify the returned URL
expect(result).toEqual({
ok: true,
data: `${WEBAPP_URL}/c/${mockToken}`,
});
});
it("adds expiration to the token when expirationDays is provided", () => {
const expirationDays = 7;
contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId, expirationDays);
// Verify JWT sign was called with expiration
expect(jwt.sign).toHaveBeenCalledWith(
{
contactId: mockEncryptedContactId,
surveyId: mockEncryptedSurveyId,
},
ENCRYPTION_KEY,
{ algorithm: "HS256", expiresIn: "7d" }
);
});
it("throws an error when ENCRYPTION_KEY is not available", async () => {
// Reset modules so the new mock is used by the module under test
vi.resetModules();
// Remock constants to simulate missing ENCRYPTION_KEY
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
WEBAPP_URL: "https://test.formbricks.com",
}));
// Reimport the modules so they pick up the new mock
const { getContactSurveyLink } = await import("./contact-survey-link");
const result = getContactSurveyLink(mockContactId, mockSurveyId);
expect(result).toEqual({
ok: false,
error: {
type: "internal_server_error",
message: "Encryption key not found - cannot create personalized survey link",
},
});
});
});
describe("verifyContactSurveyToken", () => {
it("verifies and decrypts a valid token", () => {
const result = contactSurveyLink.verifyContactSurveyToken(mockToken);
// Verify JWT verify was called
expect(jwt.verify).toHaveBeenCalledWith(mockToken, ENCRYPTION_KEY);
// Check the decrypted result
expect(result).toEqual({
ok: true,
data: {
contactId: mockContactId,
surveyId: mockSurveyId,
},
});
});
it("throws an error when token verification fails", () => {
vi.mocked(jwt.verify).mockImplementation(() => {
throw new Error("Token verification failed");
});
const result = contactSurveyLink.verifyContactSurveyToken(mockToken);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
});
}
});
it("throws an error when token has invalid format", () => {
// Mock JWT.verify to return an incomplete payload
vi.mocked(jwt.verify).mockReturnValue({
// Missing surveyId
contactId: mockEncryptedContactId,
} as any);
// Suppress console.error for this test
vi.spyOn(console, "error").mockImplementation(() => {});
const result = contactSurveyLink.verifyContactSurveyToken(mockToken);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
});
}
});
it("throws an error when ENCRYPTION_KEY is not available", async () => {
vi.resetModules();
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
WEBAPP_URL: "https://test.formbricks.com",
}));
const { verifyContactSurveyToken } = await import("./contact-survey-link");
const result = verifyContactSurveyToken(mockToken);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
message: "Encryption key not found - cannot verify survey token",
});
}
});
});
});

View File

@@ -0,0 +1,82 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import jwt from "jsonwebtoken";
import { ENCRYPTION_KEY, WEBAPP_URL } from "@formbricks/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto";
import { Result, err, ok } from "@formbricks/types/error-handlers";
// Creates an encrypted personalized survey link for a contact
export const getContactSurveyLink = (
contactId: string,
surveyId: string,
expirationDays?: number
): Result<string, ApiErrorResponseV2> => {
if (!ENCRYPTION_KEY) {
return err({
type: "internal_server_error",
message: "Encryption key not found - cannot create personalized survey link",
});
}
// Encrypt the contact and survey IDs
const encryptedContactId = symmetricEncrypt(contactId, ENCRYPTION_KEY);
const encryptedSurveyId = symmetricEncrypt(surveyId, ENCRYPTION_KEY);
// Create JWT payload with encrypted IDs
const payload = {
contactId: encryptedContactId,
surveyId: encryptedSurveyId,
};
// Set token options
const tokenOptions: jwt.SignOptions = {
algorithm: "HS256",
};
// Add expiration if specified
if (expirationDays !== undefined && expirationDays > 0) {
tokenOptions.expiresIn = `${expirationDays}d`;
}
// Sign the token with ENCRYPTION_KEY using SHA256
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
// Return the personalized URL
return ok(`${WEBAPP_URL}/c/${token}`);
};
// Validates and decrypts a contact survey JWT token
export const verifyContactSurveyToken = (
token: string
): Result<{ contactId: string; surveyId: string }, ApiErrorResponseV2> => {
if (!ENCRYPTION_KEY) {
return err({
type: "internal_server_error",
message: "Encryption key not found - cannot verify survey token",
});
}
try {
// Verify the token
const decoded = jwt.verify(token, ENCRYPTION_KEY) as { contactId: string; surveyId: string };
if (!decoded || !decoded.contactId || !decoded.surveyId) {
throw err("Invalid token format");
}
// Decrypt the contact and survey IDs
const contactId = symmetricDecrypt(decoded.contactId, ENCRYPTION_KEY);
const surveyId = symmetricDecrypt(decoded.surveyId, ENCRYPTION_KEY);
return ok({
contactId,
surveyId,
});
} catch (error) {
console.error("Error verifying contact survey token:", error);
return err({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
});
}
};

View File

@@ -30,6 +30,7 @@ interface LinkSurveyProps {
IS_FORMBRICKS_CLOUD: boolean;
locale: string;
isPreview: boolean;
contactId?: string;
}
export const LinkSurvey = ({
@@ -48,6 +49,7 @@ export const LinkSurvey = ({
IS_FORMBRICKS_CLOUD,
locale,
isPreview,
contactId,
}: LinkSurveyProps) => {
const responseId = singleUseResponse?.id;
const searchParams = useSearchParams();
@@ -198,6 +200,7 @@ export const LinkSurvey = ({
singleUseId={singleUseId}
singleUseResponseId={responseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
/>
</LinkSurveyWrapper>
);

View File

@@ -25,6 +25,7 @@ interface PinScreenProps {
isEmbed: boolean;
locale: string;
isPreview: boolean;
contactId?: string;
}
export const PinScreen = (props: PinScreenProps) => {
@@ -43,6 +44,7 @@ export const PinScreen = (props: PinScreenProps) => {
isEmbed,
locale,
isPreview,
contactId,
} = props;
const [localPinEntry, setLocalPinEntry] = useState<string>("");
@@ -75,7 +77,7 @@ export const PinScreen = (props: PinScreenProps) => {
if (isValidPin) {
setLoading(true);
const response = await validateSurveyPinAction({ surveyId, pin: localPinEntry });
if (response?.data) {
if (response?.data?.survey) {
setSurvey(response.data.survey);
} else {
const errorMessage = getFormattedErrorMessage(response);
@@ -125,6 +127,7 @@ export const PinScreen = (props: PinScreenProps) => {
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
contactId={contactId}
/>
);
};

View File

@@ -10,7 +10,7 @@ export const SurveyInactive = async ({
status,
surveyClosedMessage,
}: {
status: "paused" | "completed" | "link invalid" | "scheduled";
status: "paused" | "completed" | "link invalid" | "scheduled" | "response submitted";
surveyClosedMessage?: TSurveyClosedMessage | null;
}) => {
const t = await getTranslate();
@@ -18,12 +18,14 @@ export const SurveyInactive = async ({
paused: <PauseCircleIcon className="h-20 w-20" />,
completed: <CheckCircle2Icon className="h-20 w-20" />,
"link invalid": <HelpCircleIcon className="h-20 w-20" />,
"response submitted": <CheckCircle2Icon className="h-20 w-20" />,
};
const descriptions = {
paused: t("s.paused"),
completed: t("s.completed"),
"link invalid": t("s.link_invalid"),
"response submitted": t("s.response_submitted"),
};
return (
@@ -41,11 +43,13 @@ export const SurveyInactive = async ({
? surveyClosedMessage.subheading
: descriptions[status]}
</p>
{!(status === "completed" && surveyClosedMessage) && status !== "link invalid" && (
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">{t("s.create_your_own")}</Link>
</Button>
)}
{!(status === "completed" && surveyClosedMessage) &&
status !== "link invalid" &&
status !== "response submitted" && (
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">{t("s.create_your_own")}</Link>
</Button>
)}
</div>
<div>
<Link href="https://formbricks.com">

View File

@@ -0,0 +1,144 @@
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyRendererProps {
survey: TSurvey;
searchParams: {
verify?: string;
lang?: string;
embed?: string;
preview?: string;
};
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished"> | undefined;
contactId?: string;
isPreview: boolean;
}
export const renderSurvey = async ({
survey,
searchParams,
singleUseId,
singleUseResponse,
contactId,
isPreview,
}: SurveyRendererProps) => {
const locale = await findMatchingLocale();
const langParam = searchParams.lang;
const isEmbed = searchParams.embed === "true";
if (survey.status === "draft" || survey.type !== "link") {
notFound();
}
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error("Organization not found");
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan);
if (survey.status !== "inProgress" && !isPreview) {
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
/>
);
}
// verify email: Check if the survey requires email verification
let emailVerificationStatus = "";
let verifiedEmail: string | undefined = undefined;
if (survey.isVerifyEmailEnabled) {
const token = searchParams.verify;
if (token) {
const emailVerificationDetails = await getEmailVerificationDetails(survey.id, token);
emailVerificationStatus = emailVerificationDetails.status;
verifiedEmail = emailVerificationDetails.email;
}
}
// get project
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Project not found");
}
const getLanguageCode = (): string => {
if (!langParam || !isMultiLanguageAllowed) return "default";
else {
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}
};
const languageCode = getLanguageCode();
const isSurveyPinProtected = Boolean(survey.pin);
const responseCount = await getResponseCountBySurveyId(survey.id);
if (isSurveyPinProtected) {
return (
<PinScreen
surveyId={survey.id}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
webAppUrl={WEBAPP_URL}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
locale={locale}
isPreview={isPreview}
contactId={contactId}
/>
);
}
return (
<LinkSurvey
survey={survey}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
webAppUrl={WEBAPP_URL}
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
contactId={contactId}
/>
);
};

View File

@@ -0,0 +1,75 @@
import { verifyContactSurveyToken } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getSurvey } from "@/modules/survey/lib/survey";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
import { getExistingContactResponse } from "@/modules/survey/link/lib/response";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
interface ContactSurveyPageProps {
params: Promise<{
jwt: string;
}>;
searchParams: Promise<{
verify?: string;
lang?: string;
embed?: string;
preview?: string;
}>;
}
export const generateMetadata = async (props: ContactSurveyPageProps): Promise<Metadata> => {
const { jwt } = await props.params;
try {
// Verify and decode the JWT token
const result = verifyContactSurveyToken(jwt);
if (!result.ok) {
return {
title: "Survey",
description: "Complete this survey",
};
}
const { surveyId } = result.data;
return getBasicSurveyMetadata(surveyId);
} catch (error) {
// If the token is invalid, we'll return generic metadata
return {
title: "Survey",
description: "Complete this survey",
};
}
};
export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const { jwt } = params;
const { preview } = searchParams;
const result = verifyContactSurveyToken(jwt);
if (!result.ok) {
return <SurveyInactive status="link invalid" />;
}
const { surveyId, contactId } = result.data;
const existingResponse = await getExistingContactResponse(surveyId, contactId);
if (existingResponse) {
return <SurveyInactive status="response submitted" />;
}
const isPreview = preview === "true";
const survey = await getSurvey(surveyId);
if (!survey) {
notFound();
}
return renderSurvey({
survey,
searchParams,
contactId,
isPreview,
});
};

View File

@@ -0,0 +1,200 @@
import { getSurvey } from "@/modules/survey/lib/survey";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
import {
getBasicSurveyMetadata,
getBrandColorForURL,
getNameForURL,
getSurveyOpenGraphMetadata,
} from "./metadata-utils";
// Mock dependencies
vi.mock("@/modules/survey/lib/survey", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/link/lib/project", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
// Mock constants
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: vi.fn(() => false),
WEBAPP_URL: "https://test.formbricks.com",
}));
vi.mock("@formbricks/lib/styling/constants", () => ({
COLOR_DEFAULTS: {
brandColor: "#00c4b8",
},
}));
describe("Metadata Utils", () => {
// Reset all mocks before each test
beforeEach(() => {
vi.clearAllMocks();
});
describe("getNameForURL", () => {
it("replaces spaces with %20", () => {
const result = getNameForURL("Hello World");
expect(result).toBe("Hello%20World");
});
it("handles strings with no spaces correctly", () => {
const result = getNameForURL("HelloWorld");
expect(result).toBe("HelloWorld");
});
it("handles strings with multiple spaces", () => {
const result = getNameForURL("Hello World Test");
expect(result).toBe("Hello%20%20World%20%20Test");
});
});
describe("getBrandColorForURL", () => {
it("replaces # with %23", () => {
const result = getBrandColorForURL("#ff0000");
expect(result).toBe("%23ff0000");
});
it("handles strings with no # correctly", () => {
const result = getBrandColorForURL("ff0000");
expect(result).toBe("ff0000");
});
});
describe("getBasicSurveyMetadata", () => {
const mockSurveyId = "survey-123";
const mockEnvironmentId = "env-456";
it("returns default metadata when survey is not found", async () => {
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(result).toEqual({
title: "Survey",
description: "Complete this survey",
survey: null,
});
});
it("uses welcome card headline when available", async () => {
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
welcomeCard: {
enabled: true,
timeToFinish: false,
showResponseCount: false,
headline: {
default: "Welcome Headline",
},
html: {
default: "Welcome Description",
},
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ name: "Test Project" } as any);
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId);
expect(result).toEqual({
title: "Welcome Headline | Formbricks",
description: "Welcome Description",
survey: mockSurvey,
});
});
it("falls back to survey name when welcome card is not enabled", async () => {
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
welcomeCard: {
enabled: false,
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ name: "Test Project" } as any);
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(result).toEqual({
title: "Test Survey | Formbricks",
description: "Complete this survey",
survey: mockSurvey,
});
});
it("adds Formbricks to title when IS_FORMBRICKS_CLOUD is true", async () => {
// Change the mock for this specific test
(IS_FORMBRICKS_CLOUD as unknown as ReturnType<typeof vi.fn>).mockReturnValue(true);
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
welcomeCard: {
enabled: false,
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(result.title).toBe("Test Survey | Formbricks");
// Reset the mock
(IS_FORMBRICKS_CLOUD as unknown as ReturnType<typeof vi.fn>).mockReturnValue(false);
});
});
describe("getSurveyOpenGraphMetadata", () => {
it("generates correct OpenGraph metadata", () => {
const surveyId = "survey-123";
const surveyName = "Test Survey";
const brandColor = COLOR_DEFAULTS.brandColor.replace("#", "%23");
const encodedName = surveyName.replace(/ /g, "%20");
const result = getSurveyOpenGraphMetadata(surveyId, surveyName);
expect(result).toEqual({
metadataBase: new URL(WEBAPP_URL),
openGraph: {
title: surveyName,
description: "Thanks a lot for your time 🙏",
url: `/s/${surveyId}`,
siteName: "",
images: [`/api/v1/og?brandColor=${brandColor}&name=${encodedName}`],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: surveyName,
description: "Thanks a lot for your time 🙏",
images: [`/api/v1/og?brandColor=${brandColor}&name=${encodedName}`],
},
});
});
it("handles survey names with spaces correctly", () => {
const surveyId = "survey-123";
const surveyName = "Test Survey With Spaces";
const result = getSurveyOpenGraphMetadata(surveyId, surveyName);
expect(result.openGraph?.images?.[0]).toContain("name=Test%20Survey%20With%20Spaces");
});
});
});

View File

@@ -0,0 +1,92 @@
import { getSurvey } from "@/modules/survey/lib/survey";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { Metadata } from "next";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
/**
* Utility function to encode name for URL usage
*/
export const getNameForURL = (url: string) => url.replace(/ /g, "%20");
/**
* Utility function to encode brand color for URL usage
*/
export const getBrandColorForURL = (url: string) => url.replace(/#/g, "%23");
/**
* Get basic survey metadata (title and description) based on welcome card or survey name
*/
export const getBasicSurveyMetadata = async (surveyId: string) => {
const survey = await getSurvey(surveyId);
// If survey doesn't exist, return default metadata
if (!survey) {
return {
title: "Survey",
description: "Complete this survey",
survey: null,
};
}
const project = await getProjectByEnvironmentId(survey.environmentId);
const welcomeCard = survey.welcomeCard as TSurveyWelcomeCard;
// Set title to either welcome card headline or survey name
let title = "Survey";
if (welcomeCard.enabled && welcomeCard.headline?.default) {
title = welcomeCard.headline.default;
} else {
title = survey.name;
}
// Set description to either welcome card html content or default
let description = "Complete this survey";
if (welcomeCard.enabled && welcomeCard.html?.default) {
description = welcomeCard.html.default;
}
// Add product name in title if it's Formbricks cloud
if (IS_FORMBRICKS_CLOUD) {
title = `${title} | Formbricks`;
} else if (project) {
// Since project name is not available in the returned type, we'll just use a generic name
title = `${title} | Survey`;
}
return {
title,
description,
survey,
};
};
/**
* Generate Open Graph metadata for survey
*/
export const getSurveyOpenGraphMetadata = (surveyId: string, surveyName: string): Metadata => {
const brandColor = getBrandColorForURL(COLOR_DEFAULTS.brandColor); // Default color
const encodedName = getNameForURL(surveyName);
const ogImgURL = `/api/v1/og?brandColor=${brandColor}&name=${encodedName}`;
return {
metadataBase: new URL(WEBAPP_URL),
openGraph: {
title: surveyName,
description: "Thanks a lot for your time 🙏",
url: `/s/${surveyId}`,
siteName: "",
images: [ogImgURL],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: surveyName,
description: "Thanks a lot for your time 🙏",
images: [ogImgURL],
},
};
};

View File

@@ -69,3 +69,35 @@ export const getResponseBySingleUseId = reactCache(
}
)()
);
export const getExistingContactResponse = reactCache(
async (surveyId: string, contactId: string): Promise<Pick<Response, "id" | "finished"> | null> =>
cache(
async () => {
try {
const response = await prisma.response.findFirst({
where: {
surveyId,
contactId,
},
select: {
id: true,
finished: true,
},
});
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`link-surveys-getExisitingContactResponse-${surveyId}-${contactId}`],
{
tags: [responseCache.tag.bySurveyId(surveyId), responseCache.tag.byContactId(contactId)],
}
)()
);

View File

@@ -1,8 +1,8 @@
import { getSurveyMetadata } from "@/modules/survey/link/lib/survey";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { getBrandColorForURL, getNameForURL, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
export const getMetadataForLinkSurvey = async (surveyId: string): Promise<Metadata> => {
const survey = await getSurveyMetadata(surveyId);
@@ -13,30 +13,22 @@ export const getMetadataForLinkSurvey = async (surveyId: string): Promise<Metada
const brandColor = getBrandColorForURL(survey.styling?.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
const surveyName = getNameForURL(survey.name);
const ogImgURL = `/api/v1/og?brandColor=${brandColor}&name=${surveyName}`;
// Use the shared function for creating the base metadata but override with specific OpenGraph data
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, survey.name);
// Override with the custom image URL that uses the survey's brand color
if (baseMetadata.openGraph) {
baseMetadata.openGraph.images = [ogImgURL];
}
if (baseMetadata.twitter) {
baseMetadata.twitter.images = [ogImgURL];
}
return {
title: survey.name,
metadataBase: new URL(WEBAPP_URL),
openGraph: {
title: survey.name,
description: "Thanks a lot for your time 🙏",
url: `/s/${survey.id}`,
siteName: "",
images: [ogImgURL],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: survey.name,
description: "Thanks a lot for your time 🙏",
images: [ogImgURL],
},
...baseMetadata,
};
};
const getNameForURL = (url: string) => url.replace(/ /g, "%20");
const getBrandColorForURL = (url: string) => url.replace(/#/g, "%23");

View File

@@ -1,21 +1,11 @@
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { getSurvey } from "@/modules/survey/lib/survey";
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getResponseBySingleUseId } from "@/modules/survey/link/lib/response";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import { Response } from "@prisma/client";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { ZId } from "@formbricks/types/common";
interface LinkSurveyPageProps {
@@ -48,33 +38,13 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
if (!validId.success) {
notFound();
}
const isPreview = searchParams.preview === "true";
const survey = await getSurvey(params.surveyId);
const locale = await findMatchingLocale();
const suId = searchParams.suId;
const langParam = searchParams.lang; //can either be language code or alias
const isSingleUseSurvey = survey?.singleUse?.enabled;
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
const isEmbed = searchParams.embed === "true";
if (!survey || survey.type !== "link" || survey.status === "draft") {
notFound();
}
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error("Organization not found");
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan);
if (survey.status !== "inProgress" && !isPreview) {
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
/>
);
}
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
@@ -95,7 +65,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
singleUseId = validatedSingleUseId ?? suId;
}
let singleUseResponse: Pick<Response, "id" | "finished"> | undefined = undefined;
let singleUseResponse;
if (isSingleUseSurvey) {
try {
singleUseResponse = singleUseId
@@ -106,85 +76,11 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
}
}
// verify email: Check if the survey requires email verification
let emailVerificationStatus = "";
let verifiedEmail: string | undefined = undefined;
if (survey.isVerifyEmailEnabled) {
const token = searchParams.verify;
if (token) {
const emailVerificationDetails = await getEmailVerificationDetails(survey.id, token);
emailVerificationStatus = emailVerificationDetails.status;
verifiedEmail = emailVerificationDetails.email;
}
}
// get project and person
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Project not found");
}
const getLanguageCode = (): string => {
if (!langParam || !isMultiLanguageAllowed) return "default";
else {
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}
};
const languageCode = getLanguageCode();
const isSurveyPinProtected = Boolean(survey.pin);
const responseCount = await getResponseCountBySurveyId(survey.id);
if (isSurveyPinProtected) {
return (
<PinScreen
surveyId={survey.id}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
webAppUrl={WEBAPP_URL}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
locale={locale}
isPreview={isPreview}
/>
);
}
return (
<LinkSurvey
survey={survey}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
webAppUrl={WEBAPP_URL}
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
/>
);
return renderSurvey({
survey,
searchParams,
singleUseId,
singleUseResponse,
isPreview,
});
};

View File

@@ -1,7 +1,7 @@
// vitest.config.ts
import react from "@vitejs/plugin-react";
import { loadEnv } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
@@ -20,6 +20,7 @@ export default defineConfig({
reportsDirectory: "./coverage", // Output coverage reports to the coverage/ directory
include: [
"modules/api/v2/**/*.ts",
"modules/api/v2/**/*.tsx",
"modules/auth/lib/**/*.ts",
"modules/signup/lib/**/*.ts",
"modules/ee/whitelabel/email-customization/components/*.tsx",
@@ -27,13 +28,14 @@ export default defineConfig({
"modules/email/emails/survey/follow-up.tsx",
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
"modules/ee/sso/lib/**/*.ts",
"modules/ee/contacts/lib/**/*.ts",
"modules/survey/link/lib/**/*.ts",
"app/(auth)/layout.tsx",
"app/(app)/layout.tsx",
"app/intercom/*.tsx",
],
exclude: [
"**/.next/**",
"**/*.test.*",
"**/*.spec.*",
"**/constants.ts", // Exclude constants files
"**/route.ts", // Exclude route files

View File

@@ -1897,6 +1897,7 @@
"preview_survey_questions": "Vorschau der Fragen.",
"question_preview": "Vorschau der Frage",
"response_already_received": "Wir haben bereits eine Antwort für diese E-Mail-Adresse erhalten.",
"response_submitted": "Eine Antwort, die mit dieser Umfrage und diesem Kontakt verknüpft ist, existiert bereits",
"survey_already_answered_heading": "Die Umfrage wurde bereits beantwortet.",
"survey_already_answered_subheading": "Du kannst diesen Link nur einmal verwenden.",
"survey_sent_to": "Umfrage an {email} gesendet",

View File

@@ -1897,6 +1897,7 @@
"preview_survey_questions": "Preview survey questions.",
"question_preview": "Question Preview",
"response_already_received": "We already received a response for this email address.",
"response_submitted": "A response linked to this survey and contact already exists",
"survey_already_answered_heading": "The survey has already been answered.",
"survey_already_answered_subheading": "You can only use this link once.",
"survey_sent_to": "Survey sent to {email}",

View File

@@ -1897,6 +1897,7 @@
"preview_survey_questions": "Aperçu des questions de l'enquête.",
"question_preview": "Aperçu de la question",
"response_already_received": "Nous avons déjà reçu une réponse pour cette adresse e-mail.",
"response_submitted": "Une réponse liée à cette enquête et à ce contact existe déjà",
"survey_already_answered_heading": "L'enquête a déjà été répondue.",
"survey_already_answered_subheading": "Vous ne pouvez utiliser ce lien qu'une seule fois.",
"survey_sent_to": "Enquête envoyée à {email}",

View File

@@ -1897,6 +1897,7 @@
"preview_survey_questions": "Visualizar perguntas da pesquisa.",
"question_preview": "Prévia da Pergunta",
"response_already_received": "Já recebemos uma resposta para este endereço de email.",
"response_submitted": "Já existe uma resposta vinculada a esta pesquisa e contato",
"survey_already_answered_heading": "A pesquisa já foi respondida.",
"survey_already_answered_subheading": "Você só pode usar esse link uma vez.",
"survey_sent_to": "Pesquisa enviada para {email}",

View File

@@ -1897,6 +1897,7 @@
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
"question_preview": "Pré-visualização da Pergunta",
"response_already_received": "Já recebemos uma resposta para este endereço de email.",
"response_submitted": "Já existe uma resposta associada a este inquérito e contacto",
"survey_already_answered_heading": "O inquérito já foi respondido.",
"survey_already_answered_subheading": "Só pode usar este link uma vez.",
"survey_sent_to": "Inquérito enviado para {email}",

View File

@@ -1897,6 +1897,7 @@
"preview_survey_questions": "預覽問卷問題。",
"question_preview": "問題預覽",
"response_already_received": "我們已收到此電子郵件地址的回應。",
"response_submitted": "與此問卷和聯絡人相關的回應已經存在",
"survey_already_answered_heading": "問卷已回答。",
"survey_already_answered_subheading": "您只能使用此連結一次。",
"survey_sent_to": "問卷已發送至 '{'email'}'",