mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 09:00:18 -06:00
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:
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route";
|
||||
|
||||
export { GET };
|
||||
4
apps/web/app/c/[jwt]/page.tsx
Normal file
4
apps/web/app/c/[jwt]/page.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { ContactSurveyPage, generateMetadata } from "@/modules/survey/link/contact-survey/page";
|
||||
|
||||
export { generateMetadata };
|
||||
export default ContactSurveyPage;
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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 } });
|
||||
},
|
||||
});
|
||||
188
apps/web/modules/ee/contacts/lib/contact-survey-link.test.ts
Normal file
188
apps/web/modules/ee/contacts/lib/contact-survey-link.test.ts
Normal 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();
|
||||
// Re‑mock constants to simulate missing ENCRYPTION_KEY
|
||||
vi.doMock("@formbricks/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: undefined,
|
||||
WEBAPP_URL: "https://test.formbricks.com",
|
||||
}));
|
||||
// Re‑import 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
82
apps/web/modules/ee/contacts/lib/contact-survey-link.ts
Normal file
82
apps/web/modules/ee/contacts/lib/contact-survey-link.ts
Normal 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" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
144
apps/web/modules/survey/link/components/survey-renderer.tsx
Normal file
144
apps/web/modules/survey/link/components/survey-renderer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
75
apps/web/modules/survey/link/contact-survey/page.tsx
Normal file
75
apps/web/modules/survey/link/contact-survey/page.tsx
Normal 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,
|
||||
});
|
||||
};
|
||||
200
apps/web/modules/survey/link/lib/metadata-utils.test.ts
Normal file
200
apps/web/modules/survey/link/lib/metadata-utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
92
apps/web/modules/survey/link/lib/metadata-utils.ts
Normal file
92
apps/web/modules/survey/link/lib/metadata-utils.ts
Normal 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],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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'}'",
|
||||
|
||||
Reference in New Issue
Block a user