mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-08 02:43:06 -05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e319b44f0f | |||
| d29cfc8880 | |||
| 19178ca94d | |||
| 45040c4754 |
@@ -155,31 +155,3 @@ jobs:
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
linear-release-complete:
|
||||
name: Mark Linear release as complete
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- docker-build-community
|
||||
- docker-build-cloud
|
||||
- helm-chart-release
|
||||
- move-stable-tag
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Complete Linear release
|
||||
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
command: complete
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Linear Release Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linear-release:
|
||||
name: Sync release to Linear
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sync Linear release
|
||||
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
-11
@@ -2,7 +2,6 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
@@ -42,16 +41,6 @@ const Page = async (props: ChannelPageProps) => {
|
||||
|
||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"organization_mode_selected",
|
||||
{
|
||||
organization_id: params.organizationId,
|
||||
mode: "surveys",
|
||||
},
|
||||
{ organizationId: params.organizationId }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
|
||||
+1
-2
@@ -18,7 +18,6 @@ import { createProjectAction } from "@/app/(app)/environments/[environmentId]/ac
|
||||
import { previewSurvey } from "@/app/lib/templates";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||
@@ -243,7 +242,7 @@ export const ProjectSettings = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey(previewSurvey(projectName || t("common.my_product"), t))}
|
||||
survey={previewSurvey(projectName || t("common.my_product"), t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
|
||||
-35
@@ -7,14 +7,10 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
|
||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
@@ -43,25 +39,6 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
const channel = searchParams.channel ?? null;
|
||||
const industry = searchParams.industry ?? null;
|
||||
const mode = searchParams.mode ?? "surveys";
|
||||
|
||||
const experimentVariant =
|
||||
(await getPostHogFeatureFlag(session.user.id, "onboarding-theme-experiment")) || "control";
|
||||
|
||||
if (experimentVariant === "remove-theme") {
|
||||
const project = await createProject(params.organizationId, {
|
||||
name: organization.name,
|
||||
styling: buildStylingFromBrandColor(DEFAULT_BRAND_COLOR),
|
||||
config: { channel, industry },
|
||||
});
|
||||
const productionEnv = project.environments.find((e) => e.type === "production");
|
||||
if (channel === "app" || channel === "website") {
|
||||
return redirect(`/environments/${productionEnv?.id}/connect`);
|
||||
} else if (channel === "link") {
|
||||
return redirect(`/environments/${productionEnv?.id}/surveys`);
|
||||
}
|
||||
return redirect(`/environments/${productionEnv?.id}/xm-templates`);
|
||||
}
|
||||
|
||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||
|
||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||
@@ -74,18 +51,6 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
if (searchParams.mode === "cx") {
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"organization_mode_selected",
|
||||
{
|
||||
organization_id: params.organizationId,
|
||||
mode: "cx",
|
||||
},
|
||||
{ organizationId: params.organizationId }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
@@ -81,19 +80,6 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
groupIdentifyPostHog("workspace", project.id, { name: project.name });
|
||||
|
||||
capturePostHogEvent(
|
||||
user.id,
|
||||
"workspace_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: project.id,
|
||||
name: project.name,
|
||||
},
|
||||
{ organizationId, workspaceId: project.id }
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = project.id;
|
||||
ctx.auditLoggingCtx.newObject = project;
|
||||
|
||||
@@ -2,8 +2,6 @@ import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
|
||||
import { POSTHOG_KEY } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||
@@ -27,14 +25,6 @@ const EnvLayout = async (props: {
|
||||
return (
|
||||
<>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
{POSTHOG_KEY && (
|
||||
<PostHogGroupIdentify
|
||||
organizationId={layoutData.organization.id}
|
||||
organizationName={layoutData.organization.name}
|
||||
workspaceId={layoutData.project.id}
|
||||
workspaceName={layoutData.project.name}
|
||||
/>
|
||||
)}
|
||||
<EnvironmentContextWrapper
|
||||
environment={layoutData.environment}
|
||||
project={layoutData.project}
|
||||
|
||||
+2
-16
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -147,7 +146,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
.inputSchema(ZGeneratePersonalLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
|
||||
@@ -155,7 +153,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -163,7 +161,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId,
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -180,18 +178,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
throw new UnknownError("No contacts found for the selected segment");
|
||||
}
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"personal_link_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
survey_id: parsedInput.surveyId,
|
||||
link_count: contactsResult.length,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
|
||||
// Prepare CSV data with the specified headers and order
|
||||
const csvHeaders = [
|
||||
"Formbricks Contact ID",
|
||||
|
||||
+5
-69
@@ -2,7 +2,7 @@
|
||||
|
||||
import DOMPurify from "dompurify";
|
||||
import { CopyIcon, SendIcon } from "lucide-react";
|
||||
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
@@ -21,7 +21,6 @@ interface EmailTabProps {
|
||||
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
const [activeTab, setActiveTab] = useState("preview");
|
||||
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
|
||||
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const emailHtml = useMemo(() => {
|
||||
@@ -32,40 +31,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
.replaceAll("?preview=true", "");
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
const sanitizedEmailHtml = useMemo(() => {
|
||||
if (!emailHtmlPreview) return "";
|
||||
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
const emailPreviewDocument = useMemo(() => {
|
||||
if (!sanitizedEmailHtml) return "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="only light" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
<base target="_blank" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: only light;
|
||||
supported-color-schemes: light;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
color-scheme: only light;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${sanitizedEmailHtml}</body>
|
||||
</html>`;
|
||||
}, [sanitizedEmailHtml]);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "preview",
|
||||
@@ -86,25 +51,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
getData();
|
||||
}, [surveyId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewFrameHeight(560);
|
||||
}, [emailPreviewDocument]);
|
||||
|
||||
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
|
||||
const { contentDocument } = event.currentTarget;
|
||||
if (!contentDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHeight = Math.max(
|
||||
contentDocument.body.scrollHeight,
|
||||
contentDocument.documentElement.scrollHeight,
|
||||
560
|
||||
);
|
||||
|
||||
setPreviewFrameHeight(nextHeight);
|
||||
};
|
||||
|
||||
const sendPreviewEmail = async () => {
|
||||
try {
|
||||
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
|
||||
@@ -127,9 +73,7 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
if (activeTab === "preview") {
|
||||
return (
|
||||
<div className="space-y-4 pb-4">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
|
||||
data-testid="survey-email-preview-shell">
|
||||
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="mb-6 flex gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500" />
|
||||
@@ -143,17 +87,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
|
||||
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
|
||||
</div>
|
||||
<div data-testid="survey-email-preview-content">
|
||||
{emailPreviewDocument ? (
|
||||
<iframe
|
||||
className="mt-2 w-full rounded-md border-0 bg-white"
|
||||
data-testid="survey-email-preview-frame"
|
||||
onLoad={handlePreviewFrameLoad}
|
||||
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
|
||||
srcDoc={emailPreviewDocument}
|
||||
style={{ height: `${previewFrameHeight}px` }}
|
||||
title={t("environments.surveys.share.send_email.email_preview_tab")}
|
||||
/>
|
||||
<div className="p-2">
|
||||
{emailHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { extractEmailBodyFragment } from "./emailTemplateFragment";
|
||||
|
||||
describe("extractEmailBodyFragment", () => {
|
||||
test("returns the body contents for rendered email documents", () => {
|
||||
const html = `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<style>.foo { color: red; }</style>
|
||||
</head>
|
||||
<body class="email-body">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Preview content</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
expect(extractEmailBodyFragment(html)).toBe(
|
||||
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
|
||||
);
|
||||
});
|
||||
|
||||
test("removes document-level tags from rendered survey email markup", () => {
|
||||
const fragment = extractEmailBodyFragment(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>.foo { color: red; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Which fruits do you like</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
expect(fragment).toBe(
|
||||
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
|
||||
);
|
||||
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
|
||||
});
|
||||
|
||||
test("falls back to the original markup when no body tag exists", () => {
|
||||
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
|
||||
});
|
||||
|
||||
test("removes React server markers from rendered fragments", () => {
|
||||
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
|
||||
"<div>Preview content</div>"
|
||||
);
|
||||
});
|
||||
});
|
||||
+5
-4
@@ -1,12 +1,10 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getStyling } from "@/lib/utils/styling";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
||||
import { extractEmailBodyFragment } from "./emailTemplateFragment";
|
||||
|
||||
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
|
||||
const t = await getTranslate();
|
||||
@@ -19,9 +17,12 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const styling = getStyling(project, toJsEnvironmentStateSurvey(survey));
|
||||
const styling = getStyling(project, survey);
|
||||
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
|
||||
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
||||
const doctype =
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
||||
const htmlCleaned = html.toString().replace(doctype, "");
|
||||
|
||||
return extractEmailBodyFragment(html.toString());
|
||||
return htmlCleaned;
|
||||
};
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
|
||||
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
|
||||
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
|
||||
|
||||
export const extractEmailBodyFragment = (html: string): string => {
|
||||
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
|
||||
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
|
||||
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
|
||||
|
||||
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
|
||||
};
|
||||
@@ -42,25 +42,18 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
||||
const result = await getResponseDownloadFile(
|
||||
parsedInput.surveyId,
|
||||
parsedInput.format,
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"responses_exported",
|
||||
{
|
||||
survey_id: parsedInput.surveyId,
|
||||
format: parsedInput.format,
|
||||
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "responses_exported", {
|
||||
survey_id: parsedInput.surveyId,
|
||||
format: parsedInput.format,
|
||||
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -43,22 +43,14 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
||||
ctx.auditLoggingCtx.integrationId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: parsedInput.environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "integration_connected", {
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
|
||||
const makeParams = (responseCount: number) => ({
|
||||
organizationId: "org-1",
|
||||
workspaceId: "ws-1",
|
||||
surveyId: "survey-1",
|
||||
surveyType: "link",
|
||||
environmentId: "env-1",
|
||||
@@ -24,21 +23,15 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
|
||||
captureSurveyResponsePostHogEvent(makeParams(1));
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(
|
||||
"org-1",
|
||||
"survey_response_received",
|
||||
{
|
||||
survey_id: "survey-1",
|
||||
survey_type: "link",
|
||||
organization_id: "org-1",
|
||||
workspace_id: "ws-1",
|
||||
environment_id: "env-1",
|
||||
response_count: 1,
|
||||
is_first_response: true,
|
||||
milestone: "first",
|
||||
},
|
||||
{ organizationId: "org-1", workspaceId: "ws-1" }
|
||||
);
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
|
||||
survey_id: "survey-1",
|
||||
survey_type: "link",
|
||||
organization_id: "org-1",
|
||||
environment_id: "env-1",
|
||||
response_count: 1,
|
||||
is_first_response: true,
|
||||
milestone: "first",
|
||||
});
|
||||
});
|
||||
|
||||
test("fires on every 100th response", async () => {
|
||||
@@ -51,20 +44,10 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
test("fires on every 10th response up to 100", async () => {
|
||||
test("does NOT fire for 2nd through 99th responses", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
for (const count of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) {
|
||||
captureSurveyResponsePostHogEvent(makeParams(count));
|
||||
}
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
test("does NOT fire for non-milestone responses under 100", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
for (const count of [2, 5, 11, 25, 49, 51, 99]) {
|
||||
for (const count of [2, 5, 10, 50, 99]) {
|
||||
captureSurveyResponsePostHogEvent(makeParams(count));
|
||||
}
|
||||
|
||||
@@ -92,8 +75,7 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
expect.objectContaining({
|
||||
is_first_response: false,
|
||||
milestone: "200",
|
||||
}),
|
||||
{ organizationId: "org-1", workspaceId: "ws-1" }
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { capturePostHogEvent } from "@/lib/posthog";
|
||||
|
||||
interface SurveyResponsePostHogEventParams {
|
||||
organizationId: string;
|
||||
workspaceId: string;
|
||||
surveyId: string;
|
||||
surveyType: string;
|
||||
environmentId: string;
|
||||
@@ -11,36 +10,24 @@ interface SurveyResponsePostHogEventParams {
|
||||
|
||||
/**
|
||||
* Captures a PostHog event for survey responses at milestones:
|
||||
* 1st response, every 10th for the first 100 (10, 20, ..., 100),
|
||||
* then every 100th (200, 300, 400, ...).
|
||||
* 1st response, then every 100th (100, 200, 300, ...).
|
||||
*/
|
||||
export const captureSurveyResponsePostHogEvent = ({
|
||||
organizationId,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
surveyType,
|
||||
environmentId,
|
||||
responseCount,
|
||||
}: SurveyResponsePostHogEventParams): void => {
|
||||
const isFirst = responseCount === 1;
|
||||
const isEvery10thUnder100 = responseCount <= 100 && responseCount % 10 === 0;
|
||||
const isEvery100thAbove100 = responseCount > 100 && responseCount % 100 === 0;
|
||||
if (responseCount !== 1 && responseCount % 100 !== 0) return;
|
||||
|
||||
if (!isFirst && !isEvery10thUnder100 && !isEvery100thAbove100) return;
|
||||
|
||||
capturePostHogEvent(
|
||||
organizationId,
|
||||
"survey_response_received",
|
||||
{
|
||||
survey_id: surveyId,
|
||||
survey_type: surveyType,
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
environment_id: environmentId,
|
||||
response_count: responseCount,
|
||||
is_first_response: responseCount === 1,
|
||||
milestone: responseCount === 1 ? "first" : String(responseCount),
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
capturePostHogEvent(organizationId, "survey_response_received", {
|
||||
survey_id: surveyId,
|
||||
survey_type: surveyType,
|
||||
organization_id: organizationId,
|
||||
environment_id: environmentId,
|
||||
response_count: responseCount,
|
||||
is_first_response: responseCount === 1,
|
||||
milestone: responseCount === 1 ? "first" : String(responseCount),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
@@ -308,11 +307,9 @@ export const POST = async (request: Request) => {
|
||||
|
||||
if (POSTHOG_KEY) {
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId);
|
||||
const workspaceId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
|
||||
captureSurveyResponsePostHogEvent({
|
||||
organizationId: organization.id,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
surveyType: survey.type,
|
||||
environmentId,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
@@ -87,18 +87,10 @@ export const GET = async (req: Request) => {
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(session.user.id, "integration_connected", {
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { authenticateRequest, handleErrorResponse } from "./auth";
|
||||
import { authenticateRequest } from "./auth";
|
||||
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
|
||||
getApiKeyWithPermissions: vi.fn(),
|
||||
@@ -199,53 +193,3 @@ describe("authenticateRequest", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleErrorResponse", () => {
|
||||
test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => {
|
||||
const response = handleErrorResponse(new Error("NotAuthenticated"));
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
});
|
||||
|
||||
test("returns 401 unauthorized for 'Unauthorized' message", async () => {
|
||||
const response = handleErrorResponse(new Error("Unauthorized"));
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("unauthorized");
|
||||
});
|
||||
|
||||
test("returns 409 conflict for UniqueConstraintError", async () => {
|
||||
const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists"));
|
||||
expect(response.status).toBe(409);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("conflict");
|
||||
expect(body.message).toBe("Action with name foo already exists");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for DatabaseError", async () => {
|
||||
const response = handleErrorResponse(new DatabaseError("db boom"));
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("db boom");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for InvalidInputError", async () => {
|
||||
const response = handleErrorResponse(new InvalidInputError("bad input"));
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("bad input");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for ResourceNotFoundError", async () => {
|
||||
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 500 internalServerError for unknown errors", async () => {
|
||||
const response = handleErrorResponse(new Error("something else"));
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Some error occurred");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
|
||||
@@ -45,9 +40,6 @@ export const handleErrorResponse = (error: any): Response => {
|
||||
case "Unauthorized":
|
||||
return responses.unauthorizedResponse();
|
||||
default:
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message);
|
||||
}
|
||||
if (
|
||||
error instanceof DatabaseError ||
|
||||
error instanceof InvalidInputError ||
|
||||
|
||||
@@ -80,7 +80,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
// name intentionally omitted — internal label not needed by the SDK
|
||||
name: true,
|
||||
questions: true,
|
||||
blocks: true,
|
||||
variables: true,
|
||||
@@ -107,13 +107,13 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
styling: true,
|
||||
status: true,
|
||||
recaptcha: true,
|
||||
// Fetch only what's needed to compute the minimal segment shape.
|
||||
// Titles, descriptions, and filter conditions are evaluated server-side
|
||||
// and must not be sent to the browser.
|
||||
segment: {
|
||||
select: {
|
||||
id: true,
|
||||
filters: true,
|
||||
include: {
|
||||
surveys: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
recontactDays: true,
|
||||
@@ -147,28 +147,10 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
throw new ResourceNotFoundError("project", null);
|
||||
}
|
||||
|
||||
// Transform surveys using the shared utility, then replace the segment with
|
||||
// the minimal public shape (id + hasFilters). We null out segment before
|
||||
// calling transformPrismaSurvey because that function expects a surveys[]
|
||||
// relation on the segment object (used by the management API), which we
|
||||
// intentionally don't fetch here.
|
||||
const transformedSurveys = environmentData.surveys.map((survey) => {
|
||||
const minimalSegment = survey.segment
|
||||
? {
|
||||
id: survey.segment.id,
|
||||
hasFilters:
|
||||
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
|
||||
}
|
||||
: null;
|
||||
|
||||
const { segment: _segment, ...surveyWithoutSegment } = survey;
|
||||
const transformed = transformPrismaSurvey<TJsEnvironmentStateSurvey>({
|
||||
...surveyWithoutSegment,
|
||||
segment: null,
|
||||
});
|
||||
|
||||
return { ...transformed, segment: minimalSegment };
|
||||
});
|
||||
// Transform surveys using existing utility
|
||||
const transformedSurveys = environmentData.surveys.map((survey) =>
|
||||
transformPrismaSurvey<TJsEnvironmentStateSurvey>(survey)
|
||||
);
|
||||
|
||||
return {
|
||||
environment: {
|
||||
|
||||
+5
-21
@@ -10,11 +10,6 @@ import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||
import { getEnvironmentState } from "./environmentState";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
|
||||
vi.mock("@/modules/storage/utils", () => ({ resolveStorageUrlsInObject: vi.fn((obj: unknown) => obj) }));
|
||||
vi.mock("@/modules/survey/lib/utils", () => ({ transformPrismaSurvey: vi.fn() }));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: {
|
||||
@@ -27,9 +22,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
environment: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
@@ -171,7 +163,6 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
// Default mocks for successful retrieval
|
||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
|
||||
vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -338,18 +329,11 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
await getEnvironmentState(environmentId);
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(
|
||||
environmentId,
|
||||
"app_connected",
|
||||
{
|
||||
num_surveys: 1,
|
||||
num_code_actions: 1,
|
||||
num_no_code_actions: 1,
|
||||
organization_id: "test-org-id",
|
||||
workspace_id: "test-project-id",
|
||||
},
|
||||
{ organizationId: "test-org-id", workspaceId: "test-project-id" }
|
||||
);
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(environmentId, "app_connected", {
|
||||
num_surveys: 1,
|
||||
num_code_actions: 1,
|
||||
num_no_code_actions: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("should not capture app_connected event when app setup already completed", async () => {
|
||||
|
||||
@@ -33,25 +33,11 @@ export const getEnvironmentState = async (
|
||||
});
|
||||
|
||||
if (POSTHOG_KEY) {
|
||||
const workspaceId = environment.project.id;
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: { organizationId: true },
|
||||
capturePostHogEvent(environmentId, "app_connected", {
|
||||
num_surveys: surveys.length,
|
||||
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
|
||||
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
|
||||
});
|
||||
const organizationId = project?.organizationId;
|
||||
|
||||
capturePostHogEvent(
|
||||
environmentId,
|
||||
"app_connected",
|
||||
{
|
||||
num_surveys: surveys.length,
|
||||
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
|
||||
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
|
||||
organization_id: organizationId ?? "",
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
organizationId ? { organizationId, workspaceId } : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
const getEmail = async (token: string) => {
|
||||
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
|
||||
@@ -95,18 +95,10 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(
|
||||
authentication.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "airtable",
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "airtable",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { symmetricEncrypt } from "@/lib/crypto";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -101,18 +101,10 @@ export const GET = withV1ApiWrapper({
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(
|
||||
authentication.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "notion",
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "notion",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL }
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -109,18 +109,10 @@ export const GET = withV1ApiWrapper({
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(
|
||||
authentication.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "slack",
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "slack",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -80,11 +80,6 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.successResponse(actionClass),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return {
|
||||
response: responses.conflictResponse(error.message),
|
||||
};
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface PostHogGroupIdentifyProps {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
}
|
||||
|
||||
export const PostHogGroupIdentify = ({
|
||||
organizationId,
|
||||
organizationName,
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
}: PostHogGroupIdentifyProps) => {
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false;
|
||||
|
||||
const applyGroups = () => {
|
||||
posthog.group("organization", organizationId, { name: organizationName });
|
||||
posthog.group("workspace", workspaceId, { name: workspaceName });
|
||||
};
|
||||
|
||||
if (posthog.__loaded) {
|
||||
applyGroups();
|
||||
return;
|
||||
}
|
||||
|
||||
// PostHogIdentify (in app layout) initialises posthog from a sibling
|
||||
// useEffect; effect order isn't guaranteed, so poll briefly until loaded.
|
||||
const intervalId = setInterval(() => {
|
||||
if (cancelledRef.current) return;
|
||||
if (posthog.__loaded) {
|
||||
applyGroups();
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
cancelledRef.current = true;
|
||||
clearInterval(intervalId);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
clearInterval(intervalId);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [organizationId, organizationName, workspaceId, workspaceName]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -4,10 +4,10 @@ import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getHasNoOrganizations } from "@/lib/instance/service";
|
||||
import { gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
|
||||
@@ -21,7 +21,7 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateOrganizationAction)
|
||||
.action(
|
||||
withAuditLogging("created", "organization", async ({ ctx, parsedInput }) => {
|
||||
const hasNoOrganizations = await getHasNoOrganizations();
|
||||
const hasNoOrganizations = await gethasNoOrganizations();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
if (!hasNoOrganizations && !isMultiOrgEnabled) {
|
||||
@@ -50,17 +50,10 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
ctx.auditLoggingCtx.organizationId = newOrganization.id;
|
||||
ctx.auditLoggingCtx.newObject = newOrganization;
|
||||
|
||||
groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name });
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"organization_created",
|
||||
{
|
||||
organization_id: newOrganization.id,
|
||||
is_first_org: hasNoOrganizations,
|
||||
},
|
||||
{ organizationId: newOrganization.id }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "organization_created", {
|
||||
organization_id: newOrganization.id,
|
||||
is_first_org: hasNoOrganizations,
|
||||
});
|
||||
|
||||
return newOrganization;
|
||||
})
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getHasNoOrganizations, getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const Page = async () => {
|
||||
const [session, isFreshInstance, hasNoOrganizations] = await Promise.all([
|
||||
getServerSession(authOptions),
|
||||
getIsFreshInstance(),
|
||||
getHasNoOrganizations(),
|
||||
]);
|
||||
|
||||
if (isFreshInstance) {
|
||||
return redirect("/setup/intro");
|
||||
}
|
||||
|
||||
if (hasNoOrganizations) {
|
||||
if (session) {
|
||||
return redirect("/setup/organization/create");
|
||||
}
|
||||
|
||||
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
|
||||
}
|
||||
|
||||
return redirect("/");
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+1
-2
@@ -1192,7 +1192,6 @@ checksums:
|
||||
environments/settings/profile/update_personal_info: 5806f9bae0248a604cf85a2d8790a606
|
||||
environments/settings/profile/warning_cannot_delete_account: 07c25c3829149cd7171e7ad88229deac
|
||||
environments/settings/profile/warning_cannot_undo: dd1b2a59ff244b362d1d0d4eb1dbf7c6
|
||||
environments/settings/profile/wrong_password: e3523f78b302d11b33af6cc40d8df9da
|
||||
environments/settings/teams/add_members_description: 96e1e7125a0dfeaecc2c238eda3a216f
|
||||
environments/settings/teams/add_workspaces_description: f0f0cdd4d1032fbcb83d34a780bdfa52
|
||||
environments/settings/teams/all_members_added: 0541be1777b5c838f2e039035488506c
|
||||
@@ -1482,7 +1481,7 @@ checksums:
|
||||
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
|
||||
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
|
||||
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
|
||||
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
|
||||
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
|
||||
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
createActionClass,
|
||||
deleteActionClass,
|
||||
getActionClass,
|
||||
getActionClassByEnvironmentIdAndName,
|
||||
getActionClasses,
|
||||
updateActionClass,
|
||||
} from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -20,8 +16,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -184,147 +178,4 @@ describe("ActionClass Service", () => {
|
||||
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createActionClass", () => {
|
||||
const codeInput: TActionClassInput = {
|
||||
name: "Code Action",
|
||||
description: "desc",
|
||||
type: "code",
|
||||
key: "code-action-key",
|
||||
environmentId: "env-create",
|
||||
};
|
||||
|
||||
const buildPrismaUniqueError = (target: string[]) =>
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: { target } }
|
||||
);
|
||||
|
||||
test("should create and return the action class", async () => {
|
||||
const created: TActionClass = {
|
||||
id: "id-create",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: codeInput.name,
|
||||
description: codeInput.description ?? null,
|
||||
type: "code",
|
||||
key: codeInput.type === "code" ? codeInput.key : null,
|
||||
noCodeConfig: null,
|
||||
environmentId: codeInput.environmentId,
|
||||
};
|
||||
vi.mocked(prisma.actionClass.create).mockResolvedValue(created as never);
|
||||
|
||||
const result = await createActionClass(codeInput.environmentId, codeInput);
|
||||
expect(result).toEqual(created);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 with target field", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(buildPrismaUniqueError(["name"]));
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
`Action with name ${codeInput.name} already exists`
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 even when target is missing", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: undefined }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError for non-P2002 errors", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(DatabaseError);
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
`Database error when creating an action for environment ${codeInput.environmentId}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateActionClass", () => {
|
||||
const updateInput: Partial<TActionClassInput> = {
|
||||
name: "Renamed Action",
|
||||
description: "updated desc",
|
||||
type: "code",
|
||||
key: "renamed-key",
|
||||
environmentId: "env-update",
|
||||
};
|
||||
|
||||
const buildPrismaUniqueError = (target: string[]) =>
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: { target } }
|
||||
);
|
||||
|
||||
test("should update and return the action class", async () => {
|
||||
const updated = {
|
||||
id: "id-update",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: updateInput.name,
|
||||
description: updateInput.description ?? null,
|
||||
type: "code" as const,
|
||||
key: "renamed-key",
|
||||
noCodeConfig: null,
|
||||
environmentId: updateInput.environmentId,
|
||||
surveyTriggers: [],
|
||||
};
|
||||
vi.mocked(prisma.actionClass.update).mockResolvedValue(updated as never);
|
||||
|
||||
const result = await updateActionClass(updateInput.environmentId!, "id-update", updateInput);
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 with target field", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(buildPrismaUniqueError(["name"]));
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
`Action with name ${updateInput.name} already exists`
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError for other PrismaClientKnownRequestError codes", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("should rethrow unknown errors", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
"boom"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -135,7 +135,7 @@ export const createActionClass = async (
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
const targetField = (error.meta?.target as string[] | undefined)?.[0];
|
||||
throw new UniqueConstraintError(
|
||||
throw new DatabaseError(
|
||||
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
|
||||
);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export const updateActionClass = async (
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
const targetField = (error.meta?.target as string[] | undefined)?.[0];
|
||||
throw new UniqueConstraintError(
|
||||
throw new DatabaseError(
|
||||
`Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const getIsFreshInstance = reactCache(async (): Promise<boolean> => {
|
||||
});
|
||||
|
||||
// Function to check if there are any organizations in the database
|
||||
export const getHasNoOrganizations = reactCache(async (): Promise<boolean> => {
|
||||
export const gethasNoOrganizations = reactCache(async (): Promise<boolean> => {
|
||||
try {
|
||||
const organizationCount = await prisma.organization.count();
|
||||
return organizationCount === 0;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
|
||||
import { capturePostHogEvent } from "./capture";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
capture: vi.fn(),
|
||||
groupIdentify: vi.fn(),
|
||||
loggerWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -14,7 +13,7 @@ vi.mock("@formbricks/logger", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./server", () => ({
|
||||
posthogServerClient: { capture: mocks.capture, groupIdentify: mocks.groupIdentify },
|
||||
posthogServerClient: { capture: mocks.capture },
|
||||
}));
|
||||
|
||||
describe("capturePostHogEvent", () => {
|
||||
@@ -33,7 +32,6 @@ describe("capturePostHogEvent", () => {
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
groups: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,41 +45,6 @@ describe("capturePostHogEvent", () => {
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
groups: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("includes organization and workspace groups when provided", () => {
|
||||
capturePostHogEvent(
|
||||
"user123",
|
||||
"test_event",
|
||||
{ key: "value" },
|
||||
{ organizationId: "org_1", workspaceId: "ws_1" }
|
||||
);
|
||||
|
||||
expect(mocks.capture).toHaveBeenCalledWith({
|
||||
distinctId: "user123",
|
||||
event: "test_event",
|
||||
properties: {
|
||||
key: "value",
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
groups: { organization: "org_1", workspace: "ws_1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("includes only organization group when workspaceId missing", () => {
|
||||
capturePostHogEvent("user123", "test_event", undefined, { organizationId: "org_1" });
|
||||
|
||||
expect(mocks.capture).toHaveBeenCalledWith({
|
||||
distinctId: "user123",
|
||||
event: "test_event",
|
||||
properties: {
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
groups: { organization: "org_1" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,44 +61,6 @@ describe("capturePostHogEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupIdentifyPostHog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("calls posthog groupIdentify with correct params", () => {
|
||||
groupIdentifyPostHog("organization", "org_1", { name: "Acme" });
|
||||
|
||||
expect(mocks.groupIdentify).toHaveBeenCalledWith({
|
||||
groupType: "organization",
|
||||
groupKey: "org_1",
|
||||
properties: { name: "Acme" },
|
||||
});
|
||||
});
|
||||
|
||||
test("identifies workspace group", () => {
|
||||
groupIdentifyPostHog("workspace", "ws_1", { name: "Marketing" });
|
||||
|
||||
expect(mocks.groupIdentify).toHaveBeenCalledWith({
|
||||
groupType: "workspace",
|
||||
groupKey: "ws_1",
|
||||
properties: { name: "Marketing" },
|
||||
});
|
||||
});
|
||||
|
||||
test("does not throw when groupIdentify throws", () => {
|
||||
mocks.groupIdentify.mockImplementation(() => {
|
||||
throw new Error("Network error");
|
||||
});
|
||||
|
||||
expect(() => groupIdentifyPostHog("organization", "org_1")).not.toThrow();
|
||||
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error), groupType: "organization", groupKey: "org_1" },
|
||||
"Failed to identify PostHog group"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capturePostHogEvent with null client", () => {
|
||||
test("no-ops when posthogServerClient is null", async () => {
|
||||
vi.clearAllMocks();
|
||||
@@ -149,14 +74,11 @@ describe("capturePostHogEvent with null client", () => {
|
||||
posthogServerClient: null,
|
||||
}));
|
||||
|
||||
const { capturePostHogEvent: captureWithNullClient, groupIdentifyPostHog: identifyWithNullClient } =
|
||||
await import("./capture");
|
||||
const { capturePostHogEvent: captureWithNullClient } = await import("./capture");
|
||||
|
||||
captureWithNullClient("user123", "test_event", { key: "value" });
|
||||
identifyWithNullClient("organization", "org_1");
|
||||
|
||||
expect(mocks.capture).not.toHaveBeenCalled();
|
||||
expect(mocks.groupIdentify).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,24 +4,10 @@ import { posthogServerClient } from "./server";
|
||||
|
||||
type PostHogEventProperties = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
export type PostHogGroupContext = {
|
||||
organizationId?: string;
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
const buildGroups = (context?: PostHogGroupContext): Record<string, string> | undefined => {
|
||||
if (!context) return undefined;
|
||||
const groups: Record<string, string> = {};
|
||||
if (context.organizationId) groups.organization = context.organizationId;
|
||||
if (context.workspaceId) groups.workspace = context.workspaceId;
|
||||
return Object.keys(groups).length > 0 ? groups : undefined;
|
||||
};
|
||||
|
||||
export function capturePostHogEvent(
|
||||
distinctId: string,
|
||||
eventName: string,
|
||||
properties?: PostHogEventProperties,
|
||||
groupContext?: PostHogGroupContext
|
||||
properties?: PostHogEventProperties
|
||||
): void {
|
||||
if (!posthogServerClient) return;
|
||||
|
||||
@@ -34,29 +20,8 @@ export function capturePostHogEvent(
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
groups: buildGroups(groupContext),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error, eventName }, "Failed to capture PostHog event");
|
||||
}
|
||||
}
|
||||
|
||||
type PostHogGroupType = "organization" | "workspace";
|
||||
|
||||
export function groupIdentifyPostHog(
|
||||
groupType: PostHogGroupType,
|
||||
groupKey: string,
|
||||
properties?: Record<string, string | number | boolean | null | undefined>
|
||||
): void {
|
||||
if (!posthogServerClient) return;
|
||||
|
||||
try {
|
||||
posthogServerClient.groupIdentify({
|
||||
groupType,
|
||||
groupKey,
|
||||
properties,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error, groupType, groupKey }, "Failed to identify PostHog group");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
export { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
|
||||
export type { PostHogGroupContext } from "./capture";
|
||||
export { capturePostHogEvent } from "./capture";
|
||||
export { getPostHogFeatureFlag } from "./get-feature-flag";
|
||||
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
/**
|
||||
* Adapts a full management-side `TSurvey` into the minimal
|
||||
* `TJsEnvironmentStateSurvey` shape that the SDK widget / shared SDK utilities
|
||||
* expect. Only the segment shape needs reshaping — the rest of `TSurvey` is a
|
||||
* structural superset of the SDK survey type.
|
||||
*/
|
||||
export const toJsEnvironmentStateSurvey = (survey: TSurvey): TJsEnvironmentStateSurvey => {
|
||||
return {
|
||||
...survey,
|
||||
segment: survey.segment ? { id: survey.segment.id, hasFilters: survey.segment.filters.length > 0 } : null,
|
||||
} as unknown as TJsEnvironmentStateSurvey;
|
||||
};
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
TooManyRequestsError,
|
||||
UniqueConstraintError,
|
||||
UnknownError,
|
||||
ValidationError,
|
||||
isExpectedError,
|
||||
@@ -75,7 +74,6 @@ describe("isExpectedError (shared helper)", () => {
|
||||
"OperationNotAllowedError",
|
||||
"TooManyRequestsError",
|
||||
"InvalidPasswordResetTokenError",
|
||||
"UniqueConstraintError",
|
||||
];
|
||||
|
||||
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
|
||||
@@ -93,7 +91,6 @@ describe("isExpectedError (shared helper)", () => {
|
||||
{ ErrorClass: ValidationError, args: ["Invalid data"] },
|
||||
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
|
||||
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
|
||||
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
|
||||
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
|
||||
const error = new (ErrorClass as any)(...args);
|
||||
expect(isExpectedError(error)).toBe(true);
|
||||
@@ -189,14 +186,6 @@ describe("actionClient handleServerError", () => {
|
||||
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("UniqueConstraintError returns its message and is not sent to Sentry", async () => {
|
||||
const result = await executeThrowingAction(
|
||||
new UniqueConstraintError("Action with name foo already exists")
|
||||
);
|
||||
expect(result?.serverError).toBe("Action with name foo already exists");
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unexpected errors SHOULD be reported to Sentry", () => {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Dein Link ist abgelaufen.",
|
||||
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig.",
|
||||
"link_expired_heading": "Dein Link ist abgelaufen."
|
||||
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Akzeptiert",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Frageeinstellungen ausblenden",
|
||||
"hostname": "Hostname",
|
||||
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Immer anzeigen, wenn ausgelöst, bis eine Antwort oder Teilantwort übermittelt wurde.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
|
||||
"ignore_global_waiting_time": "Abkühlphase ignorieren",
|
||||
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
|
||||
"image": "Bild",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Bestätige deine E-Mail, um zu antworten",
|
||||
"verify_email_before_submission_button": "Überprüfen",
|
||||
"verify_email_before_submission_description": "Um an dieser Umfrage teilzunehmen, bitte bestätige deine E-Mail",
|
||||
"want_to_respond": "Möchtest Du antworten?",
|
||||
"paused_heading": "Pausiert",
|
||||
"completed_heading": "Abgeschlossen"
|
||||
"want_to_respond": "Möchtest Du antworten?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Your link is expired.",
|
||||
"link_expired_description": "The link you used is no longer valid.",
|
||||
"link_expired_heading": "Your link is expired."
|
||||
"link_expired_description": "The link you used is no longer valid."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepted",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Hide Question settings",
|
||||
"hostname": "Hostname",
|
||||
"if_you_need_more_please": "If you need more, please",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response or partial response is submitted.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response is submitted.",
|
||||
"ignore_global_waiting_time": "Ignore Cooldown Period",
|
||||
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
|
||||
"image": "Image",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Verify your email to respond",
|
||||
"verify_email_before_submission_button": "Verify",
|
||||
"verify_email_before_submission_description": "To respond to this survey, please verify your email",
|
||||
"want_to_respond": "Want to respond?",
|
||||
"paused_heading": "Paused",
|
||||
"completed_heading": "Completed"
|
||||
"want_to_respond": "Want to respond?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Tu enlace ha caducado.",
|
||||
"link_expired_description": "El enlace que has utilizado ya no es válido.",
|
||||
"link_expired_heading": "Tu enlace ha caducado."
|
||||
"link_expired_description": "El enlace que has utilizado ya no es válido."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Aceptado",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Ocultar ajustes de la pregunta",
|
||||
"hostname": "Nombre de host",
|
||||
"if_you_need_more_please": "Si necesitas más, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cada vez que se active hasta que se envíe una respuesta o respuesta parcial.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cuando se active hasta que se envíe una respuesta.",
|
||||
"ignore_global_waiting_time": "Ignorar periodo de espera",
|
||||
"ignore_global_waiting_time_description": "Esta encuesta puede mostrarse siempre que se cumplan sus condiciones, incluso si otra encuesta se mostró recientemente.",
|
||||
"image": "Imagen",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Verifica tu correo electrónico para responder",
|
||||
"verify_email_before_submission_button": "Verificar",
|
||||
"verify_email_before_submission_description": "Para responder a esta encuesta, por favor verifica tu correo electrónico",
|
||||
"want_to_respond": "¿Quieres responder?",
|
||||
"paused_heading": "Pausado",
|
||||
"completed_heading": "Completado"
|
||||
"want_to_respond": "¿Quieres responder?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Votre lien est expiré.",
|
||||
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide.",
|
||||
"link_expired_heading": "Votre lien est expiré."
|
||||
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepté",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Masquer les paramètres de la question",
|
||||
"hostname": "Nom d'hôte",
|
||||
"if_you_need_more_please": "Si vous avez besoin de plus, veuillez",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse ou une réponse partielle soit soumise.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
|
||||
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
|
||||
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
|
||||
"image": "Image",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Vérifiez votre email pour répondre.",
|
||||
"verify_email_before_submission_button": "Vérifier",
|
||||
"verify_email_before_submission_description": "Pour répondre à cette enquête, veuillez vérifier votre e-mail.",
|
||||
"want_to_respond": "Voulez-vous répondre ?",
|
||||
"paused_heading": "En pause",
|
||||
"completed_heading": "Terminé"
|
||||
"want_to_respond": "Voulez-vous répondre ?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "A hivatkozása lejárt.",
|
||||
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes.",
|
||||
"link_expired_heading": "A hivatkozása lejárt."
|
||||
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Elfogadva",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Kérdésbeállítások elrejtése",
|
||||
"hostname": "Gépnév",
|
||||
"if_you_need_more_please": "Ha többre van szüksége, akkor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Továbbra is megjelenítés minden egyes aktiváláskor, amíg választ vagy részleges választ nem küldenek be.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Maradjon megjelenítve bármikor is aktiválódott, amíg egy választ el nem küldenek.",
|
||||
"ignore_global_waiting_time": "Várakozási időszak figyelmen kívül hagyása",
|
||||
"ignore_global_waiting_time_description": "Ez a kérdőív akkor jelenhet meg, ha a feltételei teljesülnek, még akkor is, ha egy másik kérdőív jelent meg nemrég.",
|
||||
"image": "Kép",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Ellenőrizze az e-mail-címét a válaszadáshoz",
|
||||
"verify_email_before_submission_button": "Ellenőrzés",
|
||||
"verify_email_before_submission_description": "A kérdőívre való válaszadáshoz ellenőrizze az e-mail-címét",
|
||||
"want_to_respond": "Szeretne válaszolni?",
|
||||
"paused_heading": "Szüneteltetve",
|
||||
"completed_heading": "Befejezve"
|
||||
"want_to_respond": "Szeretne válaszolni?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "リンクの有効期限が切れています。",
|
||||
"link_expired_description": "使用したリンクはすでに無効です。",
|
||||
"link_expired_heading": "リンクの有効期限が切れています。"
|
||||
"link_expired_description": "使用したリンクはすでに無効です。"
|
||||
},
|
||||
"common": {
|
||||
"accepted": "承認済み",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "質問設定を非表示",
|
||||
"hostname": "ホスト名",
|
||||
"if_you_need_more_please": "さらに必要な場合は、",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "回答または一部の回答が送信されるまで、トリガーされるたびに表示し続けます。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "回答が提出されるまで、トリガーされるたびに表示し続けます。",
|
||||
"ignore_global_waiting_time": "クールダウン期間を無視",
|
||||
"ignore_global_waiting_time_description": "このフォームは、最近別のフォームが表示されていても、条件が満たされればいつでも表示できます。",
|
||||
"image": "画像",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "回答するにはメールアドレスを認証してください",
|
||||
"verify_email_before_submission_button": "認証",
|
||||
"verify_email_before_submission_description": "このフォームに回答するには、メールアドレスを認証してください",
|
||||
"want_to_respond": "回答しますか?",
|
||||
"paused_heading": "一時停止",
|
||||
"completed_heading": "完了"
|
||||
"want_to_respond": "回答しますか?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Uw link is verlopen.",
|
||||
"link_expired_description": "De link die u gebruikte is niet meer geldig.",
|
||||
"link_expired_heading": "Uw link is verlopen."
|
||||
"link_expired_description": "De link die u gebruikte is niet meer geldig."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Geaccepteerd",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Vraaginstellingen verbergen",
|
||||
"hostname": "Hostnaam",
|
||||
"if_you_need_more_please": "Als je meer nodig hebt,",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een antwoord of gedeeltelijk antwoord is ingediend.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een reactie is ingediend.",
|
||||
"ignore_global_waiting_time": "Afkoelperiode negeren",
|
||||
"ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.",
|
||||
"image": "Afbeelding",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Verifieer uw e-mailadres om te reageren",
|
||||
"verify_email_before_submission_button": "Verifiëren",
|
||||
"verify_email_before_submission_description": "Om op deze enquête te reageren, dient u uw e-mailadres te verifiëren",
|
||||
"want_to_respond": "Wilt u reageren?",
|
||||
"paused_heading": "Gepauzeerd",
|
||||
"completed_heading": "Voltooid"
|
||||
"want_to_respond": "Wilt u reageren?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Seu link está expirado.",
|
||||
"link_expired_description": "O link que você usou não é mais válido.",
|
||||
"link_expired_heading": "Seu link está expirado."
|
||||
"link_expired_description": "O link que você usou não é mais válido."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Aceito",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Ocultar configurações da pergunta",
|
||||
"hostname": "nome do host",
|
||||
"if_you_need_more_please": "Se você precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continue mostrando sempre que acionado até que uma resposta ou resposta parcial seja enviada.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar mostrando sempre que acionada até que uma resposta seja enviada.",
|
||||
"ignore_global_waiting_time": "Ignorar período de espera",
|
||||
"ignore_global_waiting_time_description": "Esta pesquisa pode ser mostrada sempre que suas condições forem atendidas, mesmo que outra pesquisa tenha sido mostrada recentemente.",
|
||||
"image": "imagem",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Verifique seu e-mail para responder",
|
||||
"verify_email_before_submission_button": "Verificar",
|
||||
"verify_email_before_submission_description": "Para responder a esta pesquisa, confirme seu e-mail",
|
||||
"want_to_respond": "Quer responder?",
|
||||
"paused_heading": "Pausado",
|
||||
"completed_heading": "Concluído"
|
||||
"want_to_respond": "Quer responder?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "O seu link expirou.",
|
||||
"link_expired_description": "O link que utilizou já não é válido.",
|
||||
"link_expired_heading": "O seu link expirou."
|
||||
"link_expired_description": "O link que utilizou já não é válido."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Aceite",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Ocultar definições da pergunta",
|
||||
"hostname": "Nome do host",
|
||||
"if_you_need_more_please": "Se precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta ou resposta parcial seja submetida.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta seja submetida.",
|
||||
"ignore_global_waiting_time": "Ignorar período de espera",
|
||||
"ignore_global_waiting_time_description": "Este inquérito pode ser mostrado sempre que as suas condições forem cumpridas, mesmo que outro inquérito tenha sido mostrado recentemente.",
|
||||
"image": "Imagem",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Verifique o seu email para responder",
|
||||
"verify_email_before_submission_button": "Verificar",
|
||||
"verify_email_before_submission_description": "Para responder a este questionário, por favor verifique o seu email",
|
||||
"want_to_respond": "Quer responder?",
|
||||
"paused_heading": "Em pausa",
|
||||
"completed_heading": "Concluído"
|
||||
"want_to_respond": "Quer responder?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Link-ul dumneavoastră a expirat.",
|
||||
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil.",
|
||||
"link_expired_heading": "Link-ul dumneavoastră a expirat."
|
||||
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Acceptat",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Ascunde setările întrebării",
|
||||
"hostname": "Nume gazdă",
|
||||
"if_you_need_more_please": "Dacă aveți nevoie de mai mult, vă rugăm",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă să afișezi de fiecare dată când este declanșat, până când se trimite un răspuns sau un răspuns parțial.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
|
||||
"ignore_global_waiting_time": "Ignoră perioada de răcire",
|
||||
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
|
||||
"image": "Imagine",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Verificați-vă emailul pentru a răspunde",
|
||||
"verify_email_before_submission_button": "Verifică",
|
||||
"verify_email_before_submission_description": "Pentru a răspunde la acest sondaj, vă rugăm să vă verificați emailul",
|
||||
"want_to_respond": "Dorești să răspunzi?",
|
||||
"paused_heading": "Pauză",
|
||||
"completed_heading": "Completat"
|
||||
"want_to_respond": "Dorești să răspunzi?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Ваша ссылка истекла.",
|
||||
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна.",
|
||||
"link_expired_heading": "Ваша ссылка истекла."
|
||||
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Принято",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Скрыть настройки вопроса",
|
||||
"hostname": "Имя хоста",
|
||||
"if_you_need_more_please": "Если вам нужно больше, пожалуйста",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Продолжай показывать при каждом срабатывании триггера, пока не будет отправлен ответ или частичный ответ.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Показывать каждый раз при срабатывании, пока не будет получен ответ.",
|
||||
"ignore_global_waiting_time": "Игнорировать период ожидания",
|
||||
"ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
|
||||
"image": "Изображение",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Подтвердите свой email, чтобы ответить",
|
||||
"verify_email_before_submission_button": "Подтвердить",
|
||||
"verify_email_before_submission_description": "Чтобы ответить на этот опрос, пожалуйста, подтвердите свой email",
|
||||
"want_to_respond": "Хотите ответить?",
|
||||
"paused_heading": "Приостановлено",
|
||||
"completed_heading": "Завершено"
|
||||
"want_to_respond": "Хотите ответить?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Din länk har gått ut.",
|
||||
"link_expired_description": "Länken du använde är inte längre giltig.",
|
||||
"link_expired_heading": "Din länk har gått ut."
|
||||
"link_expired_description": "Länken du använde är inte längre giltig."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepterad",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Dölj frågeinställningar",
|
||||
"hostname": "Värdnamn",
|
||||
"if_you_need_more_please": "Om du behöver mer, vänligen",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa varje gång det utlöses tills ett svar eller ett delsvar skickas in.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa när villkoren är uppfyllda tills ett svar skickas in.",
|
||||
"ignore_global_waiting_time": "Ignorera väntetid",
|
||||
"ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.",
|
||||
"image": "Bild",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Verifiera din e-post för att svara",
|
||||
"verify_email_before_submission_button": "Verifiera",
|
||||
"verify_email_before_submission_description": "För att svara på denna enkät, vänligen verifiera din e-post",
|
||||
"want_to_respond": "Vill du svara?",
|
||||
"paused_heading": "Pausad",
|
||||
"completed_heading": "Slutförd"
|
||||
"want_to_respond": "Vill du svara?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Bağlantınızın süresi doldu.",
|
||||
"link_expired_description": "Kullandığınız bağlantı artık geçerli değil.",
|
||||
"link_expired_heading": "Bağlantınızın süresi doldu."
|
||||
"link_expired_description": "Kullandığınız bağlantı artık geçerli değil."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Kabul Edildi",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "Soru ayarlarını gizle",
|
||||
"hostname": "Ana bilgisayar adı",
|
||||
"if_you_need_more_please": "Daha fazlasına ihtiyacınız varsa lütfen",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Bir yanıt veya kısmi yanıt gönderilene kadar her tetiklendiğinde göstermeye devam et.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Yanıt gönderilene kadar tetiklendiğinde göstermeye devam et.",
|
||||
"ignore_global_waiting_time": "Bekleme Süresini Yoksay",
|
||||
"ignore_global_waiting_time_description": "Bu survey, yakın zamanda başka bir survey gösterilmiş olsa bile koşulları sağlandığında gösterilebilir.",
|
||||
"image": "Görsel",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "Yanıtlamak için email adresinizi doğrulayın",
|
||||
"verify_email_before_submission_button": "Doğrula",
|
||||
"verify_email_before_submission_description": "Bu survey'e yanıt vermek için lütfen email adresinizi doğrulayın",
|
||||
"want_to_respond": "Yanıtlamak ister misiniz?",
|
||||
"paused_heading": "Duraklatıldı",
|
||||
"completed_heading": "Tamamlandı"
|
||||
"want_to_respond": "Yanıtlamak ister misiniz?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "您的 链接 已过期。",
|
||||
"link_expired_description": "您 使用 的 链接 已失效。",
|
||||
"link_expired_heading": "您的 链接 已过期。"
|
||||
"link_expired_description": "您 使用 的 链接 已失效。"
|
||||
},
|
||||
"common": {
|
||||
"accepted": "已接受",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "隐藏问题设置",
|
||||
"hostname": "主 机 名",
|
||||
"if_you_need_more_please": "如果您需要更多,请",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "持续在触发时显示,直到提交响应或部分响应。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
|
||||
"ignore_global_waiting_time": "忽略冷却期",
|
||||
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
|
||||
"image": "图片",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "验证 您的 邮件 以 响应",
|
||||
"verify_email_before_submission_button": "验证",
|
||||
"verify_email_before_submission_description": "要 响应 此 调查,请 验证 您的 邮件",
|
||||
"want_to_respond": "想要 参与 吗?",
|
||||
"paused_heading": "暂停",
|
||||
"completed_heading": "完成"
|
||||
"want_to_respond": "想要 参与 吗?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "您 的 連結 已過期。",
|
||||
"link_expired_description": "您 使用 的 連結 已無效。",
|
||||
"link_expired_heading": "您 的 連結 已過期。"
|
||||
"link_expired_description": "您 使用 的 連結 已無效。"
|
||||
},
|
||||
"common": {
|
||||
"accepted": "已接受",
|
||||
@@ -1554,7 +1553,7 @@
|
||||
"hide_question_settings": "隱藏問題設定",
|
||||
"hostname": "主機名稱",
|
||||
"if_you_need_more_please": "如果您需要更多,請",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "持續顯示,直到使用者提交回應或部分回應為止。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "每次觸發時都顯示,直到提交回應為止。",
|
||||
"ignore_global_waiting_time": "忽略冷卻期",
|
||||
"ignore_global_waiting_time_description": "此問卷在符合條件時即可顯示,即使最近已顯示過其他問卷。",
|
||||
"image": "圖片",
|
||||
@@ -2447,9 +2446,7 @@
|
||||
"verify_email_before_submission": "驗證您的電子郵件以回應",
|
||||
"verify_email_before_submission_button": "驗證",
|
||||
"verify_email_before_submission_description": "若要回應此問卷,請驗證您的電子郵件",
|
||||
"want_to_respond": "想要回應嗎?",
|
||||
"paused_heading": "已暫停",
|
||||
"completed_heading": "已完成"
|
||||
"want_to_respond": "想要回應嗎?"
|
||||
},
|
||||
"setup": {
|
||||
"intro": {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { verifyUserPassword } from "@/lib/user/password";
|
||||
import { deleteUser, getUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
@@ -81,8 +80,6 @@ export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown(
|
||||
|
||||
await deleteUser(ctx.user.id);
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "delete_account");
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logAccountDeletionError(ctx.user.id, error);
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("authOptions", () => {
|
||||
);
|
||||
}, 15000);
|
||||
|
||||
test("should throw generic invalid credentials error if user has no password stored", async () => {
|
||||
test("should throw error if user has no password stored", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
@@ -186,7 +186,7 @@ describe("authOptions", () => {
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
|
||||
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Invalid credentials"
|
||||
"User has no password stored"
|
||||
);
|
||||
}, 15000);
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export const authOptions: NextAuthOptions = {
|
||||
|
||||
if (!user.password) {
|
||||
logAuthAttempt("no_password_set", "credentials", "password_validation", user.id, user.email);
|
||||
throw new Error("Invalid credentials");
|
||||
throw new Error("User has no password stored");
|
||||
}
|
||||
|
||||
if (user.isActive === false) {
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { verifyInviteToken } from "@/lib/jwt";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization, getOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { createUser, updateUser } from "@/modules/auth/lib/user";
|
||||
@@ -116,15 +116,6 @@ async function handleInviteAcceptance(
|
||||
role: invite.role,
|
||||
});
|
||||
|
||||
try {
|
||||
const invitedOrganization = await getOrganization(invite.organizationId);
|
||||
if (invitedOrganization) {
|
||||
groupIdentifyPostHog("organization", invitedOrganization.id, { name: invitedOrganization.name });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn({ error, organizationId: invite.organizationId }, "Failed to identify org group in PostHog");
|
||||
}
|
||||
|
||||
if (invite.teamIds) {
|
||||
await createTeamMembership(
|
||||
{
|
||||
@@ -175,17 +166,10 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
|
||||
});
|
||||
}
|
||||
|
||||
groupIdentifyPostHog("organization", organization.id, { name: organization.name });
|
||||
|
||||
capturePostHogEvent(
|
||||
user.id,
|
||||
"organization_created",
|
||||
{
|
||||
organization_id: organization.id,
|
||||
is_first_org: true,
|
||||
},
|
||||
{ organizationId: organization.id }
|
||||
);
|
||||
capturePostHogEvent(user.id, "organization_created", {
|
||||
organization_id: organization.id,
|
||||
is_first_org: true,
|
||||
});
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
@@ -252,19 +236,12 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti
|
||||
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
|
||||
});
|
||||
|
||||
capturePostHogEvent(
|
||||
user.id,
|
||||
"user_signed_up",
|
||||
{
|
||||
auth_provider: "credentials",
|
||||
email_domain: user.email.split("@")[1],
|
||||
signup_source: parsedInput.inviteToken ? "invite" : "direct",
|
||||
invite_organization_id: ctx.auditLoggingCtx.organizationId ?? null,
|
||||
},
|
||||
ctx.auditLoggingCtx.organizationId
|
||||
? { organizationId: ctx.auditLoggingCtx.organizationId }
|
||||
: undefined
|
||||
);
|
||||
capturePostHogEvent(user.id, "user_signed_up", {
|
||||
auth_provider: "credentials",
|
||||
email_domain: user.email.split("@")[1],
|
||||
signup_source: parsedInput.inviteToken ? "invite" : "direct",
|
||||
invite_organization_id: ctx.auditLoggingCtx.organizationId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
if (user) {
|
||||
|
||||
@@ -220,14 +220,9 @@ export const startHobbyAction = authenticatedActionClient
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
|
||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"stayed_on_hobby_plan",
|
||||
{
|
||||
organization_id: parsedInput.organizationId,
|
||||
},
|
||||
{ organizationId: parsedInput.organizationId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "stayed_on_hobby_plan", {
|
||||
organization_id: parsedInput.organizationId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
@@ -262,25 +257,15 @@ export const startProTrialAction = authenticatedActionClient
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
|
||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"free_trial_started",
|
||||
{
|
||||
plan: "pro",
|
||||
organization_id: parsedInput.organizationId,
|
||||
trial_duration_days: 14,
|
||||
},
|
||||
{ organizationId: parsedInput.organizationId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "free_trial_started", {
|
||||
plan: "pro",
|
||||
organization_id: parsedInput.organizationId,
|
||||
trial_duration_days: 14,
|
||||
});
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"reverse_trial_started",
|
||||
{
|
||||
organization_id: parsedInput.organizationId,
|
||||
},
|
||||
{ organizationId: parsedInput.organizationId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "reverse_trial_started", {
|
||||
organization_id: parsedInput.organizationId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import Stripe from "stripe";
|
||||
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({
|
||||
findOrganizationIdByStripeCustomerId: vi.fn(),
|
||||
reconcileCloudStripeSubscriptionsForOrganization: vi.fn(),
|
||||
syncOrganizationBillingFromStripe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./stripe-client", () => ({
|
||||
getStripeClient: vi.fn(),
|
||||
getStripeWebhookSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
const hobbyProduct = {
|
||||
id: "prod_U8jd2XNJgewiiA",
|
||||
metadata: { formbricks_plan: "hobby" },
|
||||
deleted: false,
|
||||
} as Stripe.Product;
|
||||
|
||||
const legacyFreeProduct = {
|
||||
id: "prod_U8jeQdtjaUrVUf",
|
||||
metadata: { formbricks_plan: "custom" },
|
||||
deleted: false,
|
||||
} as Stripe.Product;
|
||||
|
||||
const proProduct = {
|
||||
id: "prod_pro",
|
||||
metadata: { formbricks_plan: "pro" },
|
||||
deleted: false,
|
||||
} as Stripe.Product;
|
||||
|
||||
const makeSubscriptionUpdatedEvent = (opts: {
|
||||
product: Stripe.Product | Stripe.Product[];
|
||||
previousAttributes: Record<string, unknown>;
|
||||
}): Stripe.Event =>
|
||||
({
|
||||
type: "customer.subscription.updated",
|
||||
data: {
|
||||
object: {
|
||||
items: {
|
||||
data: Array.isArray(opts.product)
|
||||
? opts.product.map((p) => ({ price: { product: p } }))
|
||||
: [{ price: { product: opts.product } }],
|
||||
},
|
||||
},
|
||||
previous_attributes: opts.previousAttributes,
|
||||
},
|
||||
}) as unknown as Stripe.Event;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("isFreePlanSubscriptionRenewal", () => {
|
||||
let isFreePlanSubscriptionRenewal: (event: Stripe.Event) => boolean;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import("./stripe-webhook");
|
||||
isFreePlanSubscriptionRenewal = mod.isFreePlanSubscriptionRenewal;
|
||||
});
|
||||
|
||||
test("returns true for hobby subscription with only billing period changes", () => {
|
||||
const event = makeSubscriptionUpdatedEvent({
|
||||
product: hobbyProduct,
|
||||
previousAttributes: {
|
||||
current_period_start: 1710000000,
|
||||
current_period_end: 1712678400,
|
||||
latest_invoice: "inv_old",
|
||||
},
|
||||
});
|
||||
|
||||
expect(isFreePlanSubscriptionRenewal(event)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for legacy free subscription renewal", () => {
|
||||
const event = makeSubscriptionUpdatedEvent({
|
||||
product: legacyFreeProduct,
|
||||
previousAttributes: {
|
||||
current_period_start: 1710000000,
|
||||
current_period_end: 1712678400,
|
||||
},
|
||||
});
|
||||
|
||||
expect(isFreePlanSubscriptionRenewal(event)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for pro subscription renewal", () => {
|
||||
const event = makeSubscriptionUpdatedEvent({
|
||||
product: proProduct,
|
||||
previousAttributes: {
|
||||
current_period_start: 1710000000,
|
||||
current_period_end: 1712678400,
|
||||
},
|
||||
});
|
||||
|
||||
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when items changed (plan upgrade)", () => {
|
||||
const event = makeSubscriptionUpdatedEvent({
|
||||
product: hobbyProduct,
|
||||
previousAttributes: {
|
||||
current_period_start: 1710000000,
|
||||
items: { data: [] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when status changed (cancellation)", () => {
|
||||
const event = makeSubscriptionUpdatedEvent({
|
||||
product: hobbyProduct,
|
||||
previousAttributes: {
|
||||
status: "active",
|
||||
current_period_end: 1712678400,
|
||||
},
|
||||
});
|
||||
|
||||
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-subscription-updated events", () => {
|
||||
const event = {
|
||||
type: "customer.subscription.created",
|
||||
data: { object: {}, previous_attributes: {} },
|
||||
} as unknown as Stripe.Event;
|
||||
|
||||
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when previous_attributes is missing", () => {
|
||||
const event = {
|
||||
type: "customer.subscription.updated",
|
||||
data: {
|
||||
object: {
|
||||
items: { data: [{ price: { product: hobbyProduct } }] },
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe.Event;
|
||||
|
||||
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when product is a string (not expanded)", () => {
|
||||
const event = {
|
||||
type: "customer.subscription.updated",
|
||||
data: {
|
||||
object: {
|
||||
items: { data: [{ price: { product: "prod_U8jd2XNJgewiiA" } }] },
|
||||
},
|
||||
previous_attributes: { current_period_start: 1710000000 },
|
||||
},
|
||||
} as unknown as Stripe.Event;
|
||||
|
||||
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when only billing_cycle_anchor changes", () => {
|
||||
const event = makeSubscriptionUpdatedEvent({
|
||||
product: hobbyProduct,
|
||||
previousAttributes: {
|
||||
billing_cycle_anchor: 1710000000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(isFreePlanSubscriptionRenewal(event)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for mixed free and pro items", () => {
|
||||
const event = makeSubscriptionUpdatedEvent({
|
||||
product: [hobbyProduct, proProduct],
|
||||
previousAttributes: { current_period_start: 1710000000 },
|
||||
});
|
||||
|
||||
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for unknown product ID", () => {
|
||||
const unknownProduct = {
|
||||
id: "prod_unknown",
|
||||
metadata: { formbricks_plan: "hobby" },
|
||||
deleted: false,
|
||||
} as Stripe.Product;
|
||||
|
||||
const event = makeSubscriptionUpdatedEvent({
|
||||
product: unknownProduct,
|
||||
previousAttributes: { current_period_start: 1710000000 },
|
||||
});
|
||||
|
||||
expect(isFreePlanSubscriptionRenewal(event)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,52 @@ const resolveOrganizationId = async (eventObject: Stripe.Event.Data.Object): Pro
|
||||
return await findOrganizationIdByStripeCustomerId(customerId);
|
||||
};
|
||||
|
||||
const FREE_PLAN_PRODUCT_IDS = new Set(["prod_U8jd2XNJgewiiA", "prod_U8jeQdtjaUrVUf"]);
|
||||
|
||||
/**
|
||||
* Detects free-tier subscription renewals that only roll the billing period forward.
|
||||
* These $0 plan renewals (hobby, legacy free) generate ~10k events/month and don't
|
||||
* change any meaningful billing state — skipping them avoids unnecessary processing
|
||||
* and downstream webhook noise.
|
||||
*/
|
||||
export const isFreePlanSubscriptionRenewal = (event: Stripe.Event): boolean => {
|
||||
if (event.type !== "customer.subscription.updated") return false;
|
||||
|
||||
const subscription = event.data.object;
|
||||
const previousAttributes = (event.data as { previous_attributes?: Record<string, unknown> })
|
||||
.previous_attributes;
|
||||
|
||||
if (!previousAttributes) return false;
|
||||
|
||||
// Check that every line item belongs to a known free plan product
|
||||
const items = subscription.items?.data;
|
||||
if (!items?.length) return false;
|
||||
|
||||
const allFreePlan = items.every((item) => {
|
||||
const product = item.price?.product;
|
||||
if (!product || typeof product === "string" || product.deleted) return false;
|
||||
return FREE_PLAN_PRODUCT_IDS.has(product.id);
|
||||
});
|
||||
|
||||
if (!allFreePlan) return false;
|
||||
|
||||
// A pure renewal only touches billing period fields and latest_invoice.
|
||||
// If items or status changed, this is a real update (upgrade, cancellation, etc.)
|
||||
const changedKeys = new Set(Object.keys(previousAttributes));
|
||||
const renewalOnlyKeys = new Set([
|
||||
"current_period_start",
|
||||
"current_period_end",
|
||||
"latest_invoice",
|
||||
"billing_cycle_anchor",
|
||||
]);
|
||||
|
||||
for (const key of changedKeys) {
|
||||
if (!renewalOnlyKeys.has(key)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getUnresolvedOrganizationResponse = (event: Stripe.Event) => {
|
||||
logger.warn(
|
||||
{ eventType: event.type, eventId: event.id },
|
||||
@@ -138,6 +184,11 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
|
||||
return { status: 200, message: { received: true } };
|
||||
}
|
||||
|
||||
if (isFreePlanSubscriptionRenewal(event)) {
|
||||
logger.debug({ eventId: event.id }, "Skipping free plan subscription renewal");
|
||||
return { status: 200, message: { received: true } };
|
||||
}
|
||||
|
||||
const eventObject = event.data.object as Stripe.Event.Data.Object;
|
||||
const organizationId = await resolveOrganizationId(eventObject);
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
|
||||
@@ -55,17 +54,6 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient
|
||||
throw new InvalidInputError(errorMessage);
|
||||
}
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"personal_link_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
survey_id: parsedInput.surveyId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
|
||||
return {
|
||||
surveyUrl: result.data,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributesInput } from "@formbricks/types/contact-attribute";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import {
|
||||
@@ -115,10 +113,6 @@ export const createContactsFromCSVAction = authenticatedActionClient
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||
const existingContactCount = await prisma.contact.count({
|
||||
where: { environmentId: parsedInput.environmentId },
|
||||
});
|
||||
const result = await createContactsFromCSV(
|
||||
parsedInput.csvData,
|
||||
parsedInput.environmentId,
|
||||
@@ -130,20 +124,6 @@ export const createContactsFromCSVAction = authenticatedActionClient
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
contacts: result.contacts,
|
||||
};
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"contact_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: parsedInput.environmentId,
|
||||
existing_contact_count: existingContactCount,
|
||||
creation_method: "import",
|
||||
import_count: result.contacts.length,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
@@ -63,18 +62,6 @@ export const createContactAttributeKeyAction = authenticatedActionClient
|
||||
|
||||
ctx.auditLoggingCtx.newObject = contactAttributeKey;
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"contact_attribute_key_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: parsedInput.environmentId,
|
||||
key: parsedInput.key,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
|
||||
return contactAttributeKey;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { loadNewSegmentInSurvey } from "@/lib/survey/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -60,7 +59,6 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
|
||||
|
||||
// Set the organizationId in the context to be used in the audit log
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user?.id ?? "",
|
||||
@@ -73,7 +71,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -94,18 +92,6 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
|
||||
ctx.auditLoggingCtx.segmentId = segment.id;
|
||||
ctx.auditLoggingCtx.newObject = segment;
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user?.id ?? "",
|
||||
"segment_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: parsedInput.environmentId,
|
||||
is_private: parsedInput.isPrivate ?? false,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
|
||||
return segment;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Prisma, Response } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getQuotas } from "./quotas";
|
||||
import { evaluateQuotas, handleQuotas } from "./utils";
|
||||
@@ -53,13 +52,7 @@ export const evaluateResponseQuotas = async (input: QuotaEvaluationInput): Promi
|
||||
return { shouldEndSurvey: false };
|
||||
}
|
||||
const isDefaultLanguage = survey.languages.find((lang) => lang.default)?.language.code === language;
|
||||
const result = evaluateQuotas(
|
||||
toJsEnvironmentStateSurvey(survey),
|
||||
data,
|
||||
variables,
|
||||
quotas,
|
||||
isDefaultLanguage ? "default" : language
|
||||
);
|
||||
const result = evaluateQuotas(survey, data, variables, quotas, isDefaultLanguage ? "default" : language);
|
||||
|
||||
const quotaFull = await handleQuotas(surveyId, responseId, result, responseFinished, prismaClient);
|
||||
|
||||
|
||||
@@ -264,14 +264,6 @@ const provisionNewSsoUser = async ({
|
||||
"License and instance configuration checked"
|
||||
);
|
||||
|
||||
if (!isFirstUser && !isMultiOrgEnabled && SKIP_INVITE_FOR_SSO && !DEFAULT_TEAM_ID) {
|
||||
contextLogger.error(
|
||||
{ reason: "missing_default_team_id" },
|
||||
"SSO callback rejected: AUTH_SKIP_INVITE_FOR_SSO is enabled but AUTH_SSO_DEFAULT_TEAM_ID is not configured. Refusing to auto-provision new SSO user into an arbitrary organization."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
|
||||
if (!callbackUrl) {
|
||||
contextLogger.debug(
|
||||
|
||||
@@ -120,21 +120,12 @@ vi.mock("@formbricks/logger", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const constantsOverrides = vi.hoisted(() => ({
|
||||
SKIP_INVITE_FOR_SSO: false as boolean,
|
||||
DEFAULT_TEAM_ID: "team-123" as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
get SKIP_INVITE_FOR_SSO() {
|
||||
return constantsOverrides.SKIP_INVITE_FOR_SSO;
|
||||
},
|
||||
get DEFAULT_TEAM_ID() {
|
||||
return constantsOverrides.DEFAULT_TEAM_ID;
|
||||
},
|
||||
SKIP_INVITE_FOR_SSO: 0,
|
||||
DEFAULT_TEAM_ID: "team-123",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -156,8 +147,6 @@ const transactionUser = {
|
||||
describe("handleSsoCallback", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
constantsOverrides.SKIP_INVITE_FOR_SSO = false;
|
||||
constantsOverrides.DEFAULT_TEAM_ID = "team-123";
|
||||
|
||||
vi.mocked(prisma.$transaction).mockImplementation(
|
||||
async (callback: (tx: any) => unknown) =>
|
||||
@@ -716,80 +705,6 @@ describe("handleSsoCallback", () => {
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects auto-provisioning when SKIP_INVITE_FOR_SSO is enabled but DEFAULT_TEAM_ID is missing", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
|
||||
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
|
||||
constantsOverrides.DEFAULT_TEAM_ID = undefined;
|
||||
|
||||
const result = await handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(getFirstOrganization).not.toHaveBeenCalled();
|
||||
expect(getOrganizationByTeamId).not.toHaveBeenCalled();
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
expect(createMembership).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects auto-provisioning when DEFAULT_TEAM_ID is empty string", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
|
||||
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
|
||||
constantsOverrides.DEFAULT_TEAM_ID = "";
|
||||
|
||||
const result = await handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(getFirstOrganization).not.toHaveBeenCalled();
|
||||
expect(createUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows auto-provisioning when SKIP_INVITE_FOR_SSO and DEFAULT_TEAM_ID are both set", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
|
||||
constantsOverrides.SKIP_INVITE_FOR_SSO = true;
|
||||
constantsOverrides.DEFAULT_TEAM_ID = "team-123";
|
||||
vi.mocked(createUser).mockResolvedValue(
|
||||
mockCreatedUser("Auto Provisioned User") as typeof mockUser & {
|
||||
notificationSettings: { alert: Record<string, never>; unsubscribedOrganizationIds: string[] };
|
||||
}
|
||||
);
|
||||
transactionAccount.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await handleSsoCallback({
|
||||
user: mockUser,
|
||||
account: mockAccount,
|
||||
callbackUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(getOrganizationByTeamId).toHaveBeenCalledWith("team-123");
|
||||
expect(getFirstOrganization).not.toHaveBeenCalled();
|
||||
expect(createMembership).toHaveBeenCalledWith(
|
||||
mockOrganization.id,
|
||||
mockUser.id,
|
||||
{ role: "member", accepted: true },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
test("assigns invited SSO users into the resolved organization and syncs notification settings", async () => {
|
||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
@@ -65,39 +64,9 @@ export const updateProjectBrandingAction = authenticatedActionClient.inputSchema
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = parsedInput.projectId;
|
||||
const oldProject = await getProject(parsedInput.projectId);
|
||||
ctx.auditLoggingCtx.oldObject = oldProject;
|
||||
ctx.auditLoggingCtx.oldObject = await getProject(parsedInput.projectId);
|
||||
const result = await updateProjectBranding(parsedInput.projectId, parsedInput.data);
|
||||
ctx.auditLoggingCtx.newObject = await getProject(parsedInput.projectId);
|
||||
|
||||
const groupContext = { organizationId, workspaceId: parsedInput.projectId };
|
||||
|
||||
if (oldProject?.linkSurveyBranding === true && parsedInput.data.linkSurveyBranding === false) {
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"remove_branding_enabled",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: parsedInput.projectId,
|
||||
branding_type: "link",
|
||||
},
|
||||
groupContext
|
||||
);
|
||||
}
|
||||
|
||||
if (oldProject?.inAppSurveyBranding === true && parsedInput.data.inAppSurveyBranding === false) {
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"remove_branding_enabled",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: parsedInput.projectId,
|
||||
branding_type: "in_app",
|
||||
},
|
||||
groupContext
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,445 +0,0 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { renderEmbedSurveyPreviewEmail } from "@formbricks/email";
|
||||
import { exampleData } from "@formbricks/email/src/lib/example-data";
|
||||
import { embedSurveyPreviewEmailHtml } from "@formbricks/email/src/lib/fixtures/embed-survey-preview-email-html";
|
||||
import { t as mockT } from "@formbricks/email/src/lib/mock-translate";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { extractEmailBodyFragment } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplateFragment";
|
||||
import { mixColor } from "@/lib/utils/colors";
|
||||
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
||||
import {
|
||||
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS,
|
||||
EMBED_SURVEY_PREVIEW_CHOICE_IDS,
|
||||
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS,
|
||||
EMBED_SURVEY_PREVIEW_CTA_URL,
|
||||
EMBED_SURVEY_PREVIEW_HEADLINE,
|
||||
EMBED_SURVEY_PREVIEW_LOCALE,
|
||||
EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS,
|
||||
EMBED_SURVEY_PREVIEW_MATRIX_ROWS,
|
||||
EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER,
|
||||
EMBED_SURVEY_PREVIEW_PICTURE_CHOICES,
|
||||
EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
EMBED_SURVEY_PREVIEW_STYLING,
|
||||
EMBED_SURVEY_PREVIEW_SURVEY_URL,
|
||||
createEmbedSurveyPreviewEmailSurvey,
|
||||
} from "@/modules/email/fixtures/embed-survey-preview-email-fixture";
|
||||
|
||||
const mockPreviewT = mockT as unknown as TFunction;
|
||||
|
||||
const renderPreviewHtml = (type?: TSurveyElementTypeEnum) =>
|
||||
getPreviewEmailTemplateHtml(
|
||||
createEmbedSurveyPreviewEmailSurvey(type),
|
||||
EMBED_SURVEY_PREVIEW_SURVEY_URL,
|
||||
EMBED_SURVEY_PREVIEW_STYLING,
|
||||
EMBED_SURVEY_PREVIEW_LOCALE,
|
||||
mockPreviewT
|
||||
);
|
||||
|
||||
const renderPreviewFragment = async (type?: TSurveyElementTypeEnum) =>
|
||||
extractEmailBodyFragment(await renderPreviewHtml(type));
|
||||
|
||||
const normalizeStyleAttribute = (style: string) =>
|
||||
style
|
||||
.split(";")
|
||||
.map((declaration) => declaration.trim().replace(/\s*:\s*/g, ":"))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(";");
|
||||
|
||||
const normalizeHtml = (html: string) =>
|
||||
html
|
||||
.replace(
|
||||
/style="([^"]*)"/g,
|
||||
(_match: string, style: string) => `style="${normalizeStyleAttribute(style)}"`
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\s*([:;])\s*/g, "$1")
|
||||
.replace(/\s*=\s*/g, "=")
|
||||
.replace(/="\s+/g, '="')
|
||||
.replace(/>\s+/g, ">")
|
||||
.replace(/\s+</g, "<")
|
||||
.replace(/\s+>/g, ">")
|
||||
.trim();
|
||||
|
||||
const decodeSerializedHrefEntities = (html: string) => html.replaceAll("&", "&");
|
||||
|
||||
const expectSharedPreviewSignals = (html: string) => {
|
||||
expect(html).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
|
||||
expect(html).toContain(EMBED_SURVEY_PREVIEW_HEADLINE);
|
||||
expect(html).toContain("skipPrefilled=true");
|
||||
expect(html).toContain(
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples)}`
|
||||
);
|
||||
expect(html).toContain(
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas)}`
|
||||
);
|
||||
expect(html).toContain(
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples)}`
|
||||
);
|
||||
expect(html).toContain("utm_source=email_branding");
|
||||
};
|
||||
|
||||
const expectPreviewFragmentBaseSignals = (html: string) => {
|
||||
expect(html).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
|
||||
expect(html).toContain(EMBED_SURVEY_PREVIEW_HEADLINE);
|
||||
};
|
||||
|
||||
const runtimeCoverageCases = [
|
||||
{
|
||||
name: "open text",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
expectedTexts: [EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER],
|
||||
expectedHrefFragments: ["preview=true"],
|
||||
},
|
||||
{
|
||||
name: "consent",
|
||||
type: TSurveyElementTypeEnum.Consent,
|
||||
expectedTexts: ["I agree to be contacted", "Accept", "Reject"],
|
||||
expectedHrefFragments: [
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=accepted&skipPrefilled=true`,
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=dismissed&skipPrefilled=true`,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "nps",
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
expectedTexts: ["Not likely", "Very likely"],
|
||||
expectedHrefFragments: [`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=0&skipPrefilled=true`],
|
||||
},
|
||||
{
|
||||
name: "cta",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
expectedTexts: ["Open the docs"],
|
||||
expectedHrefFragments: [EMBED_SURVEY_PREVIEW_CTA_URL],
|
||||
},
|
||||
{
|
||||
name: "rating",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
expectedTexts: ["Poor", "Great"],
|
||||
expectedHrefFragments: [`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=1&skipPrefilled=true`],
|
||||
},
|
||||
{
|
||||
name: "picture selection",
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
expectedTexts: [],
|
||||
expectedHrefFragments: [
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=embed-survey-preview-picture-1&skipPrefilled=true`,
|
||||
EMBED_SURVEY_PREVIEW_PICTURE_CHOICES[0],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "cal",
|
||||
type: TSurveyElementTypeEnum.Cal,
|
||||
expectedTexts: ["Schedule your meeting"],
|
||||
expectedHrefFragments: ["preview=true"],
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
type: TSurveyElementTypeEnum.Date,
|
||||
expectedTexts: ["Select a date"],
|
||||
expectedHrefFragments: ["preview=true"],
|
||||
},
|
||||
{
|
||||
name: "matrix",
|
||||
type: TSurveyElementTypeEnum.Matrix,
|
||||
expectedTexts: [EMBED_SURVEY_PREVIEW_MATRIX_ROWS[0], EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS[0], "Continue"],
|
||||
expectedHrefFragments: ["preview=true"],
|
||||
},
|
||||
{
|
||||
name: "address",
|
||||
type: TSurveyElementTypeEnum.Address,
|
||||
expectedTexts: [
|
||||
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1,
|
||||
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.city,
|
||||
EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.country,
|
||||
],
|
||||
expectedHrefFragments: ["preview=true"],
|
||||
},
|
||||
{
|
||||
name: "ranking",
|
||||
type: TSurveyElementTypeEnum.Ranking,
|
||||
expectedTexts: ["Apples", "Bananas", "Pineapples"],
|
||||
expectedHrefFragments: ["preview=true"],
|
||||
},
|
||||
{
|
||||
name: "contact info",
|
||||
type: TSurveyElementTypeEnum.ContactInfo,
|
||||
expectedTexts: [
|
||||
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName,
|
||||
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.email,
|
||||
EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.company,
|
||||
],
|
||||
expectedHrefFragments: ["preview=true"],
|
||||
},
|
||||
{
|
||||
name: "file upload",
|
||||
type: TSurveyElementTypeEnum.FileUpload,
|
||||
expectedTexts: ["Click or drag to upload files."],
|
||||
expectedHrefFragments: ["preview=true"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe("renderEmbedSurveyPreviewEmail", () => {
|
||||
test("keeps the checked-in preview fixture aligned with the runtime survey email behaviors", async () => {
|
||||
const runtimeFragment = await renderPreviewFragment();
|
||||
|
||||
expectSharedPreviewSignals(runtimeFragment);
|
||||
expectSharedPreviewSignals(embedSurveyPreviewEmailHtml);
|
||||
expect(runtimeFragment).toContain("font-family:Inter, Helvetica, Arial, sans-serif");
|
||||
expect(embedSurveyPreviewEmailHtml).toContain("font-family:Inter, Helvetica, Arial, sans-serif");
|
||||
expect(runtimeFragment).toContain("color-scheme:only light");
|
||||
expect(embedSurveyPreviewEmailHtml).toContain("color-scheme:only light");
|
||||
expect(runtimeFragment).toContain('bgcolor="#4a865f"');
|
||||
expect(embedSurveyPreviewEmailHtml).toContain('bgcolor="#4a865f"');
|
||||
expect(runtimeFragment).not.toContain('bgcolor="#ffffff"');
|
||||
expect(embedSurveyPreviewEmailHtml).not.toContain('bgcolor="#ffffff"');
|
||||
expect(runtimeFragment).not.toContain("background-image");
|
||||
expect(embedSurveyPreviewEmailHtml).not.toContain("background-image");
|
||||
expect(runtimeFragment).toContain("background-color:#ffffff !important");
|
||||
expect(embedSurveyPreviewEmailHtml).toContain("background-color:#ffffff !important");
|
||||
expect(runtimeFragment).toContain("border-radius:0.25rem");
|
||||
expect(embedSurveyPreviewEmailHtml).toContain("border-radius:0.25rem");
|
||||
expect(runtimeFragment).not.toContain("☐");
|
||||
expect(embedSurveyPreviewEmailHtml).not.toContain("☐");
|
||||
expect(normalizeHtml(runtimeFragment)).toBe(normalizeHtml(embedSurveyPreviewEmailHtml));
|
||||
});
|
||||
|
||||
test("renders the checked-in survey preview fixture inside the React Email wrapper", async () => {
|
||||
const renderedHtml = await renderEmbedSurveyPreviewEmail({
|
||||
...exampleData.embedSurveyPreviewEmail,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(exampleData.embedSurveyPreviewEmail.html).toBe(embedSurveyPreviewEmailHtml);
|
||||
expect(exampleData.embedSurveyPreviewEmail.html).toContain(EMBED_SURVEY_PREVIEW_HEADLINE);
|
||||
expect(exampleData.embedSurveyPreviewEmail.html).toContain("border-radius:0.25rem");
|
||||
expect(exampleData.embedSurveyPreviewEmail.html).toContain("Apples");
|
||||
expect(exampleData.embedSurveyPreviewEmail.html).not.toContain("Example Survey Embed");
|
||||
expect(exampleData.embedSurveyPreviewEmail.html).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
|
||||
expect(renderedHtml).toContain('name="color-scheme"');
|
||||
expect(renderedHtml).toContain('content="only light"');
|
||||
expect(renderedHtml).toContain('name="supported-color-schemes"');
|
||||
expect(renderedHtml).not.toContain("@media");
|
||||
expect(renderedHtml).not.toContain("background-image");
|
||||
expect(normalizeHtml(renderedHtml)).toContain(normalizeHtml(embedSurveyPreviewEmailHtml));
|
||||
});
|
||||
|
||||
test.each(runtimeCoverageCases)(
|
||||
"covers the $name preview branch without nested document markup",
|
||||
async ({ type, expectedTexts, expectedHrefFragments }) => {
|
||||
const fragment = await renderPreviewFragment(type);
|
||||
const decodedHrefFragment = decodeSerializedHrefEntities(fragment);
|
||||
|
||||
expectPreviewFragmentBaseSignals(fragment);
|
||||
|
||||
for (const expectedText of expectedTexts) {
|
||||
expect(fragment).toContain(expectedText);
|
||||
}
|
||||
|
||||
for (const expectedHrefFragment of expectedHrefFragments) {
|
||||
expect(decodedHrefFragment).toContain(expectedHrefFragment);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("uses per-option prefill links for multiple-choice preview emails", async () => {
|
||||
const multiChoiceFragment = await renderPreviewFragment(TSurveyElementTypeEnum.MultipleChoiceMulti);
|
||||
|
||||
expect(multiChoiceFragment).toContain(
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples)}`
|
||||
);
|
||||
expect(multiChoiceFragment).toContain(
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas)}`
|
||||
);
|
||||
expect(multiChoiceFragment).toContain(
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples)}`
|
||||
);
|
||||
expect(multiChoiceFragment).toContain("skipPrefilled=true");
|
||||
expect(multiChoiceFragment).toContain("border-radius:0.25rem");
|
||||
expect(multiChoiceFragment).toContain("height:1rem");
|
||||
expect(multiChoiceFragment).not.toContain("☐");
|
||||
expect(multiChoiceFragment).not.toMatch(/<a\b[^>]*style="[^"]*width:\s*100%/i);
|
||||
});
|
||||
|
||||
test("uses stable encoded choice ids for single-choice prefilling links", async () => {
|
||||
const singleChoiceFragment = await renderPreviewFragment(TSurveyElementTypeEnum.MultipleChoiceSingle);
|
||||
|
||||
expect(singleChoiceFragment).toContain(
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples)}`
|
||||
);
|
||||
expect(singleChoiceFragment).toContain(
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas)}`
|
||||
);
|
||||
expect(singleChoiceFragment).toContain(
|
||||
`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples)}`
|
||||
);
|
||||
expect(singleChoiceFragment).toContain("skipPrefilled=true");
|
||||
expect(singleChoiceFragment).toContain("border-radius:9999px");
|
||||
expect(singleChoiceFragment).not.toContain("◯");
|
||||
});
|
||||
|
||||
test("keeps preview controls close to survey UI styling", async () => {
|
||||
const [
|
||||
rankingFragment,
|
||||
npsFragment,
|
||||
ratingFragment,
|
||||
dateFragment,
|
||||
ctaFragment,
|
||||
fileUploadFragment,
|
||||
addressFragment,
|
||||
contactInfoFragment,
|
||||
matrixFragment,
|
||||
] = await Promise.all([
|
||||
renderPreviewFragment(TSurveyElementTypeEnum.Ranking),
|
||||
renderPreviewFragment(TSurveyElementTypeEnum.NPS),
|
||||
renderPreviewFragment(TSurveyElementTypeEnum.Rating),
|
||||
renderPreviewFragment(TSurveyElementTypeEnum.Date),
|
||||
renderPreviewFragment(TSurveyElementTypeEnum.CTA),
|
||||
renderPreviewFragment(TSurveyElementTypeEnum.FileUpload),
|
||||
renderPreviewFragment(TSurveyElementTypeEnum.Address),
|
||||
renderPreviewFragment(TSurveyElementTypeEnum.ContactInfo),
|
||||
renderPreviewFragment(TSurveyElementTypeEnum.Matrix),
|
||||
]);
|
||||
|
||||
expect(rankingFragment).toContain("1px dashed #22c55e");
|
||||
expect(npsFragment).toContain("box-sizing:border-box");
|
||||
expect(npsFragment).toContain("height:47px");
|
||||
expect(npsFragment).toContain("line-height:41px");
|
||||
expect(npsFragment).toContain("border-radius:8px 0 0 8px");
|
||||
expect(npsFragment).toContain("border-radius:0 8px 8px 0");
|
||||
expect(npsFragment).toContain("border-left:0 !important");
|
||||
expect(npsFragment).not.toContain("line-height:120%");
|
||||
expect(npsFragment).not.toContain("mso-text-raise");
|
||||
expect(npsFragment).not.toContain("padding-left:4px");
|
||||
expect(ratingFragment).toContain("height:47px");
|
||||
expect(ratingFragment).toContain("line-height:41px");
|
||||
expect(ratingFragment).toContain("border-radius:8px 0 0 8px");
|
||||
expect(ratingFragment).toContain("border-radius:0 8px 8px 0");
|
||||
expect(ratingFragment).toContain("border-left:0 !important");
|
||||
expect(ratingFragment).toMatch(/target="_blank"\s*>1<\/a/);
|
||||
expect(ratingFragment).not.toContain("height:46px");
|
||||
expect(dateFragment).toContain("lucide-calendar-days");
|
||||
expect(ctaFragment).toContain("text-align:left");
|
||||
expect(ctaFragment).toContain("lucide-square-arrow-out-up-right");
|
||||
expect(ctaFragment).not.toContain("↗");
|
||||
expect(fileUploadFragment).toContain("lucide-upload");
|
||||
expect(fileUploadFragment).toContain("2px dashed #d6e4dc");
|
||||
expect(fileUploadFragment).toContain("height:96px");
|
||||
expect(fileUploadFragment).toContain("line-height:96px");
|
||||
expect(fileUploadFragment).not.toContain("#64748b");
|
||||
expect(addressFragment).toContain("width:100%");
|
||||
expect(addressFragment).toMatch(
|
||||
new RegExp(`<p[^>]*>\\s*${EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1}\\s*</p>`)
|
||||
);
|
||||
expect(addressFragment).not.toContain(`>${EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1}</a>`);
|
||||
expect(contactInfoFragment).toContain("box-sizing:border-box");
|
||||
expect(contactInfoFragment).toMatch(
|
||||
new RegExp(`<p[^>]*>\\s*${EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName}\\s*</p>`)
|
||||
);
|
||||
expect(contactInfoFragment).not.toContain(`>${EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName}</a>`);
|
||||
expect(matrixFragment).toContain("text-align:center");
|
||||
expect(matrixFragment).toContain("height:1rem;width:1rem");
|
||||
expect(matrixFragment).toContain("border-radius:9999px");
|
||||
expect(matrixFragment).not.toContain("margin-right:0.75rem");
|
||||
});
|
||||
|
||||
test("matches survey OpenText input sizing and placeholder opacity", async () => {
|
||||
const singleLineSurvey = createEmbedSurveyPreviewEmailSurvey(TSurveyElementTypeEnum.OpenText);
|
||||
const openTextQuestion = singleLineSurvey.blocks[0].elements[0];
|
||||
|
||||
if (openTextQuestion.type !== TSurveyElementTypeEnum.OpenText) {
|
||||
throw new Error("Expected open text question fixture");
|
||||
}
|
||||
|
||||
openTextQuestion.headline = {
|
||||
default:
|
||||
'<p class="fb-editor-paragraph" dir="ltr"><span style="white-space: pre-wrap;">Open text block</span></p>',
|
||||
};
|
||||
openTextQuestion.longAnswer = false;
|
||||
|
||||
const openTextStyling = {
|
||||
...EMBED_SURVEY_PREVIEW_STYLING,
|
||||
inputColor: { light: "#fcedf0" },
|
||||
inputTextColor: { light: "#901629" },
|
||||
inputPlaceholderOpacity: 0.5,
|
||||
inputHeight: 20,
|
||||
};
|
||||
const expectedPlaceholderColor = mixColor(mixColor("#901629", "#ffffff", 0.3), "#fcedf0", 0.5);
|
||||
const singleLineHtml = await getPreviewEmailTemplateHtml(
|
||||
singleLineSurvey,
|
||||
EMBED_SURVEY_PREVIEW_SURVEY_URL,
|
||||
openTextStyling,
|
||||
EMBED_SURVEY_PREVIEW_LOCALE,
|
||||
mockPreviewT
|
||||
);
|
||||
const singleLineFragment = extractEmailBodyFragment(singleLineHtml);
|
||||
|
||||
expect(singleLineFragment).toContain(EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER);
|
||||
expect(singleLineFragment).toContain('class="fb-editor-paragraph" dir="ltr" style="margin:0"');
|
||||
expect(singleLineFragment).toContain("min-height:20px");
|
||||
expect(singleLineFragment).toContain(`color:${expectedPlaceholderColor} !important`);
|
||||
expect(singleLineFragment).toContain("text-decoration:none !important");
|
||||
expect(singleLineFragment).toContain("background-color:#fcedf0 !important");
|
||||
expect(singleLineFragment).toMatch(
|
||||
/<div\b[^>]*style="[^"]*background-color:#fcedf0 !important[^"]*border-radius:8px[^"]*overflow:hidden[^"]*padding:8px 8px/
|
||||
);
|
||||
expect(singleLineFragment).toContain("overflow:hidden");
|
||||
expect(singleLineFragment).toContain("padding:8px 8px");
|
||||
expect(singleLineFragment).not.toMatch(/<td\b[^>]*bgcolor="#fcedf0"/);
|
||||
expect(singleLineFragment).toMatch(/<a\b[\s\S]*?>\s*<span\b/i);
|
||||
expect(singleLineFragment).toContain(`>${EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER}</span`);
|
||||
expect(singleLineFragment).not.toMatch(/<a\b[^>]*>\s*<p\b/i);
|
||||
expect(singleLineFragment).not.toMatch(/<a\b[^>]*border:1px solid/i);
|
||||
expect(singleLineFragment).not.toContain("min-height:64px");
|
||||
|
||||
openTextQuestion.longAnswer = true;
|
||||
|
||||
const longAnswerHtml = await getPreviewEmailTemplateHtml(
|
||||
singleLineSurvey,
|
||||
EMBED_SURVEY_PREVIEW_SURVEY_URL,
|
||||
openTextStyling,
|
||||
EMBED_SURVEY_PREVIEW_LOCALE,
|
||||
mockPreviewT
|
||||
);
|
||||
const longAnswerFragment = extractEmailBodyFragment(longAnswerHtml);
|
||||
|
||||
expect(longAnswerFragment).toContain("min-height:64px");
|
||||
expect(longAnswerFragment).toContain("background-color:#fcedf0 !important");
|
||||
expect(longAnswerFragment).toMatch(
|
||||
/<div\b[^>]*style="[^"]*background-color:#fcedf0 !important[^"]*border-radius:8px[^"]*overflow:hidden[^"]*padding:8px 8px/
|
||||
);
|
||||
expect(longAnswerFragment).toContain("overflow:hidden");
|
||||
expect(longAnswerFragment).toContain("padding:8px 8px");
|
||||
expect(longAnswerFragment).not.toMatch(/<td\b[^>]*bgcolor="#fcedf0"/);
|
||||
expect(longAnswerFragment).toContain("text-decoration:none !important");
|
||||
expect(longAnswerFragment).toMatch(/<a\b[\s\S]*?>\s*<span\b/i);
|
||||
expect(longAnswerFragment).toContain(`>${EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER}</span`);
|
||||
expect(longAnswerFragment).not.toMatch(/<a\b[^>]*>\s*<p\b/i);
|
||||
expect(longAnswerFragment).not.toMatch(/<a\b[^>]*border:1px solid/i);
|
||||
});
|
||||
|
||||
test("renders star ratings with SVG icons instead of emoji", async () => {
|
||||
const starRatingSurvey = createEmbedSurveyPreviewEmailSurvey(TSurveyElementTypeEnum.Rating);
|
||||
const ratingQuestion = starRatingSurvey.blocks[0].elements[0];
|
||||
|
||||
if (ratingQuestion.type !== TSurveyElementTypeEnum.Rating) {
|
||||
throw new Error("Expected rating question fixture");
|
||||
}
|
||||
|
||||
ratingQuestion.scale = "star";
|
||||
ratingQuestion.isColorCodingEnabled = false;
|
||||
|
||||
const starRatingHtml = await getPreviewEmailTemplateHtml(
|
||||
starRatingSurvey,
|
||||
EMBED_SURVEY_PREVIEW_SURVEY_URL,
|
||||
EMBED_SURVEY_PREVIEW_STYLING,
|
||||
EMBED_SURVEY_PREVIEW_LOCALE,
|
||||
mockPreviewT
|
||||
);
|
||||
const starRatingFragment = extractEmailBodyFragment(starRatingHtml);
|
||||
|
||||
expect(starRatingFragment).toContain("lucide-star");
|
||||
expect(starRatingFragment).not.toContain("⭐");
|
||||
});
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
buildBlock,
|
||||
buildCTAElement,
|
||||
buildConsentElement,
|
||||
buildMultipleChoiceElement,
|
||||
buildNPSElement,
|
||||
buildOpenTextElement,
|
||||
buildRatingElement,
|
||||
} from "@/app/lib/survey-block-builder";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
|
||||
const fixtureT = ((key: string) => key) as TFunction;
|
||||
|
||||
export const EMBED_SURVEY_PREVIEW_SURVEY_ID = "embed-survey-preview-survey";
|
||||
export const EMBED_SURVEY_PREVIEW_BLOCK_ID = "embed-survey-preview-block";
|
||||
export const EMBED_SURVEY_PREVIEW_QUESTION_ID = "embed-survey-preview-question";
|
||||
|
||||
export const EMBED_SURVEY_PREVIEW_CHOICE_IDS = {
|
||||
apples: "embed-survey-preview-choice-apples",
|
||||
bananas: "embed-survey-preview-choice-bananas",
|
||||
pineapples: "embed-survey-preview-choice-pineapples",
|
||||
} as const;
|
||||
|
||||
export const EMBED_SURVEY_PREVIEW_HEADLINE = "Which fruits do you like";
|
||||
export const EMBED_SURVEY_PREVIEW_CHOICES = ["Apples", "Bananas", "Pineapples"] as const;
|
||||
export const EMBED_SURVEY_PREVIEW_SURVEY_URL = "https://app.formbricks.com/s/embed-survey-preview";
|
||||
export const EMBED_SURVEY_PREVIEW_LOCALE = "en-US";
|
||||
export const EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER = "Share your thoughts";
|
||||
export const EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS = {
|
||||
addressLine1: "Street address",
|
||||
city: "City",
|
||||
country: "Country",
|
||||
} as const;
|
||||
export const EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS = {
|
||||
firstName: "First name",
|
||||
email: "Work email",
|
||||
company: "Company",
|
||||
} as const;
|
||||
export const EMBED_SURVEY_PREVIEW_MATRIX_ROWS = ["Product quality", "Ease of use"] as const;
|
||||
export const EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS = ["Poor", "Great"] as const;
|
||||
export const EMBED_SURVEY_PREVIEW_PICTURE_CHOICES = [
|
||||
"https://app.formbricks.com/static/media/powered-by-formbricks.7aec4b1c.svg",
|
||||
"https://app.formbricks.com/static/media/powered-by-formbricks.7aec4b1c.svg?variant=2",
|
||||
] as const;
|
||||
export const EMBED_SURVEY_PREVIEW_CTA_URL = "https://formbricks.com/docs";
|
||||
|
||||
export const EMBED_SURVEY_PREVIEW_STYLING: TSurveyStyling = {
|
||||
brandColor: { light: "#22c55e" },
|
||||
cardBackgroundColor: { light: "#4a865f" },
|
||||
cardBorderColor: { light: "#4a865f" },
|
||||
inputColor: { light: "#ffffff" },
|
||||
inputBorderColor: { light: "#d6e4dc" },
|
||||
questionColor: { light: "#1f2937" },
|
||||
roundness: 8,
|
||||
};
|
||||
|
||||
const createChoiceElement = (
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti | TSurveyElementTypeEnum.MultipleChoiceSingle
|
||||
) =>
|
||||
buildMultipleChoiceElement({
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
type,
|
||||
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
|
||||
choices: [...EMBED_SURVEY_PREVIEW_CHOICES],
|
||||
choiceIds: [
|
||||
EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples,
|
||||
EMBED_SURVEY_PREVIEW_CHOICE_IDS.bananas,
|
||||
EMBED_SURVEY_PREVIEW_CHOICE_IDS.pineapples,
|
||||
],
|
||||
required: true,
|
||||
shuffleOption: "none",
|
||||
});
|
||||
|
||||
const createPreviewSurvey = (element: TSurveyElement): TSurvey =>
|
||||
({
|
||||
id: EMBED_SURVEY_PREVIEW_SURVEY_ID,
|
||||
name: "Embed Survey Preview",
|
||||
type: "link",
|
||||
status: "inProgress",
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
},
|
||||
blocks: [
|
||||
buildBlock({
|
||||
id: EMBED_SURVEY_PREVIEW_BLOCK_ID,
|
||||
name: "Block 1",
|
||||
elements: [element],
|
||||
buttonLabel: "Next",
|
||||
backButtonLabel: "Back",
|
||||
t: fixtureT,
|
||||
}),
|
||||
],
|
||||
endings: [],
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
},
|
||||
variables: [],
|
||||
styling: EMBED_SURVEY_PREVIEW_STYLING,
|
||||
surveyClosedMessage: {
|
||||
enabled: false,
|
||||
},
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
isCaptureIpEnabled: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
}) as unknown as TSurvey;
|
||||
|
||||
const createPreviewElementByType = (type: TSurveyElementTypeEnum): TSurveyElement => {
|
||||
switch (type) {
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
return createChoiceElement(type);
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
return buildOpenTextElement({
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
|
||||
placeholder: EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER,
|
||||
inputType: "text",
|
||||
required: true,
|
||||
longAnswer: true,
|
||||
});
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
return buildConsentElement({
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
|
||||
subheader: "We need your permission",
|
||||
label: "I agree to be contacted",
|
||||
required: false,
|
||||
});
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
return buildNPSElement({
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
|
||||
lowerLabel: "Not likely",
|
||||
upperLabel: "Very likely",
|
||||
required: true,
|
||||
isColorCodingEnabled: true,
|
||||
});
|
||||
case TSurveyElementTypeEnum.CTA:
|
||||
return buildCTAElement({
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
|
||||
subheader: "Read more about Formbricks",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: "Open the docs",
|
||||
buttonUrl: EMBED_SURVEY_PREVIEW_CTA_URL,
|
||||
});
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
return buildRatingElement({
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
headline: EMBED_SURVEY_PREVIEW_HEADLINE,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: "Poor",
|
||||
upperLabel: "Great",
|
||||
required: true,
|
||||
isColorCodingEnabled: true,
|
||||
});
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return {
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
type,
|
||||
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
|
||||
required: true,
|
||||
allowMulti: false,
|
||||
choices: EMBED_SURVEY_PREVIEW_PICTURE_CHOICES.map((imageUrl, index) => ({
|
||||
id: `embed-survey-preview-picture-${index + 1}`,
|
||||
imageUrl,
|
||||
})),
|
||||
} as TSurveyElement;
|
||||
case TSurveyElementTypeEnum.Cal:
|
||||
return {
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
type,
|
||||
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
|
||||
subheader: createI18nString("Book a time with us", []),
|
||||
required: false,
|
||||
calUserName: "demo-user",
|
||||
} as TSurveyElement;
|
||||
case TSurveyElementTypeEnum.Date:
|
||||
return {
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
type,
|
||||
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
|
||||
required: true,
|
||||
format: "M-d-y",
|
||||
} as TSurveyElement;
|
||||
case TSurveyElementTypeEnum.Matrix:
|
||||
return {
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
type,
|
||||
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
|
||||
required: true,
|
||||
shuffleOption: "none",
|
||||
rows: EMBED_SURVEY_PREVIEW_MATRIX_ROWS.map((label, index) => ({
|
||||
id: `embed-survey-preview-row-${index + 1}`,
|
||||
label: createI18nString(label, []),
|
||||
})),
|
||||
columns: EMBED_SURVEY_PREVIEW_MATRIX_COLUMNS.map((label, index) => ({
|
||||
id: `embed-survey-preview-column-${index + 1}`,
|
||||
label: createI18nString(label, []),
|
||||
})),
|
||||
} as TSurveyElement;
|
||||
case TSurveyElementTypeEnum.Address:
|
||||
return {
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
type,
|
||||
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
|
||||
required: true,
|
||||
addressLine1: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.addressLine1, []),
|
||||
},
|
||||
addressLine2: {
|
||||
show: false,
|
||||
required: false,
|
||||
placeholder: createI18nString("Address line 2", []),
|
||||
},
|
||||
city: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.city, []),
|
||||
},
|
||||
state: {
|
||||
show: false,
|
||||
required: false,
|
||||
placeholder: createI18nString("State", []),
|
||||
},
|
||||
zip: {
|
||||
show: false,
|
||||
required: false,
|
||||
placeholder: createI18nString("ZIP code", []),
|
||||
},
|
||||
country: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_ADDRESS_PLACEHOLDERS.country, []),
|
||||
},
|
||||
} as TSurveyElement;
|
||||
case TSurveyElementTypeEnum.Ranking:
|
||||
return {
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
type,
|
||||
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
|
||||
required: true,
|
||||
shuffleOption: "none",
|
||||
choices: [...EMBED_SURVEY_PREVIEW_CHOICES].map((choice, index) => ({
|
||||
id: `embed-survey-preview-ranking-${index + 1}`,
|
||||
label: createI18nString(choice, []),
|
||||
})),
|
||||
} as TSurveyElement;
|
||||
case TSurveyElementTypeEnum.ContactInfo:
|
||||
return {
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
type,
|
||||
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
|
||||
required: true,
|
||||
firstName: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.firstName, []),
|
||||
},
|
||||
lastName: {
|
||||
show: false,
|
||||
required: false,
|
||||
placeholder: createI18nString("Last name", []),
|
||||
},
|
||||
email: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.email, []),
|
||||
},
|
||||
phone: {
|
||||
show: false,
|
||||
required: false,
|
||||
placeholder: createI18nString("Phone", []),
|
||||
},
|
||||
company: {
|
||||
show: true,
|
||||
required: false,
|
||||
placeholder: createI18nString(EMBED_SURVEY_PREVIEW_CONTACT_PLACEHOLDERS.company, []),
|
||||
},
|
||||
} as TSurveyElement;
|
||||
case TSurveyElementTypeEnum.FileUpload:
|
||||
return {
|
||||
id: EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
type,
|
||||
headline: createI18nString(EMBED_SURVEY_PREVIEW_HEADLINE, []),
|
||||
required: true,
|
||||
allowMultipleFiles: false,
|
||||
} as TSurveyElement;
|
||||
}
|
||||
};
|
||||
|
||||
export const createEmbedSurveyPreviewEmailSurvey = (
|
||||
type: TSurveyElementTypeEnum = TSurveyElementTypeEnum.MultipleChoiceMulti
|
||||
): TSurvey => createPreviewSurvey(createPreviewElementByType(type));
|
||||
@@ -1,500 +0,0 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { COLOR_DEFAULTS, STYLE_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { isLight, mixColor } from "@/lib/utils/colors";
|
||||
|
||||
export interface PreviewEmailStyleTokens {
|
||||
accentBackgroundColor: string;
|
||||
brandColor: string;
|
||||
buttonBackgroundColor: string;
|
||||
buttonBorderRadius: string;
|
||||
buttonFontSize: string;
|
||||
buttonFontWeight: string;
|
||||
buttonPaddingX: string;
|
||||
buttonPaddingY: string;
|
||||
buttonTextColor: string;
|
||||
cardBackgroundColor: string;
|
||||
cardBorderColor: string;
|
||||
elementDescriptionColor: string;
|
||||
elementDescriptionFontSize: string;
|
||||
elementDescriptionFontWeight: string;
|
||||
elementHeadlineColor: string;
|
||||
elementHeadlineFontSize: string;
|
||||
elementHeadlineFontWeight: string;
|
||||
elementUpperLabelColor: string;
|
||||
elementUpperLabelFontSize: string;
|
||||
elementUpperLabelFontWeight: string;
|
||||
fontFamily: string;
|
||||
inputBackgroundColor: string;
|
||||
inputBorderRadius: string;
|
||||
inputFontSize: string;
|
||||
inputHeight: string;
|
||||
inputPaddingX: string;
|
||||
inputPaddingY: string;
|
||||
inputPlaceholderEmailColor: string;
|
||||
inputShadow: string;
|
||||
inputTextColor: string;
|
||||
inputColor: string;
|
||||
inputBorderColor: string;
|
||||
optionBackgroundColor: string;
|
||||
optionBorderColor: string;
|
||||
optionBorderRadius: string;
|
||||
optionFontSize: string;
|
||||
optionLabelColor: string;
|
||||
optionPaddingX: string;
|
||||
optionPaddingY: string;
|
||||
questionColor: string;
|
||||
roundness: number;
|
||||
signatureColor: string;
|
||||
}
|
||||
|
||||
export type PreviewMarkerVariant = "checkbox" | "radio" | "ranking";
|
||||
type PreviewStyleValue = string | number | null | undefined;
|
||||
|
||||
export const PREVIEW_LINK_TARGET = "_blank";
|
||||
const SURVEY_EMAIL_FONT_FAMILY = "Inter, Helvetica, Arial, sans-serif";
|
||||
const FORCE_LIGHT_COLOR_SCHEME = "only light";
|
||||
|
||||
export const CHOICE_LINK_CLASSNAME =
|
||||
"bg-option-bg border-option-border text-option-label block rounded-option border border-solid px-option-x py-option-y font-survey text-option font-normal leading-5 no-underline";
|
||||
export const SECONDARY_BUTTON_CLASSNAME =
|
||||
"border-input-border inline-block rounded-button border border-solid px-button-x py-button-y font-survey text-button font-button leading-5 no-underline";
|
||||
export const SCALE_BUTTON_CLASSNAME =
|
||||
"border-input-border block w-full border border-solid p-0 text-center font-survey text-sm font-medium no-underline box-border";
|
||||
const CHOICE_MARKER_CLASSNAME = "inline-block h-4 w-4 box-border align-middle leading-4";
|
||||
|
||||
const EMAIL_PREVIEW_ACCENT_COLORS = {
|
||||
"bg-emerald-100": "#d1fae5",
|
||||
"bg-orange-100": "#ffedd5",
|
||||
"bg-rose-100": "#ffe4e6",
|
||||
"emerald-100": "#d1fae5",
|
||||
"orange-100": "#ffedd5",
|
||||
"rose-100": "#ffe4e6",
|
||||
} as const;
|
||||
|
||||
const RICH_TEXT_PARAGRAPH_TAG_REGEX = /<p\b([^>]*)>/gi;
|
||||
const RICH_TEXT_STYLE_ATTRIBUTE_REGEX = /\sstyle=(["'])(.*?)\1/i;
|
||||
const RICH_TEXT_STYLE_ATTRIBUTE_REPLACE_REGEX = /\sstyle=(["'])(.*?)\1/gi;
|
||||
|
||||
export const importantStyle = (value: string): string => `${value} !important`;
|
||||
|
||||
export const normalizeRichTextSpacing = (html: string): string =>
|
||||
html.replaceAll(RICH_TEXT_PARAGRAPH_TAG_REGEX, (_tag, attributes: string = "") => {
|
||||
if (RICH_TEXT_STYLE_ATTRIBUTE_REGEX.test(attributes)) {
|
||||
return `<p${attributes.replaceAll(
|
||||
RICH_TEXT_STYLE_ATTRIBUTE_REPLACE_REGEX,
|
||||
(_styleAttribute, quote: string, styleValue: string) => {
|
||||
const trimmedStyle = styleValue.trim();
|
||||
const styleWithMargin = trimmedStyle ? `${trimmedStyle};margin:0` : "margin:0";
|
||||
|
||||
return ` style=${quote}${styleWithMargin}${quote}`;
|
||||
}
|
||||
)}>`;
|
||||
}
|
||||
|
||||
return `<p${attributes} style="margin:0">`;
|
||||
});
|
||||
|
||||
const getPreviewDimension = (value: PreviewStyleValue, fallback: PreviewStyleValue): string => {
|
||||
const resolvedValue = value ?? fallback;
|
||||
|
||||
if (resolvedValue === null || resolvedValue === undefined || resolvedValue === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof resolvedValue === "number") {
|
||||
return `${resolvedValue}px`;
|
||||
}
|
||||
|
||||
if (typeof resolvedValue === "string" && !Number.isNaN(Number(resolvedValue))) {
|
||||
return `${resolvedValue}px`;
|
||||
}
|
||||
|
||||
return typeof resolvedValue === "string" ? resolvedValue : "";
|
||||
};
|
||||
|
||||
const getPreviewFontWeight = (value: PreviewStyleValue, fallback: PreviewStyleValue): string => {
|
||||
const resolvedValue = value ?? fallback;
|
||||
|
||||
if (resolvedValue === null || resolvedValue === undefined || resolvedValue === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return typeof resolvedValue === "string" || typeof resolvedValue === "number"
|
||||
? resolvedValue.toString()
|
||||
: "";
|
||||
};
|
||||
|
||||
const getPreviewOpacity = (value: PreviewStyleValue, fallback: PreviewStyleValue): number => {
|
||||
const resolvedValue = value ?? fallback;
|
||||
const parsedValue = Number(resolvedValue);
|
||||
|
||||
if (!Number.isFinite(parsedValue)) return 1;
|
||||
|
||||
return Math.min(Math.max(parsedValue, 0), 1);
|
||||
};
|
||||
|
||||
export const getForcedBackgroundStyle = (color: string): CSSProperties => ({
|
||||
background: importantStyle(color),
|
||||
backgroundColor: importantStyle(color),
|
||||
colorScheme: FORCE_LIGHT_COLOR_SCHEME,
|
||||
});
|
||||
|
||||
export const getForcedColorStyle = (color: string): CSSProperties => ({
|
||||
color: importantStyle(color),
|
||||
colorScheme: FORCE_LIGHT_COLOR_SCHEME,
|
||||
});
|
||||
|
||||
const getPreviewRoundness = (roundness: TSurveyStyling["roundness"]): number => {
|
||||
if (typeof roundness === "number") {
|
||||
return Number.isFinite(roundness) ? roundness : 8;
|
||||
}
|
||||
|
||||
if (typeof roundness === "string") {
|
||||
const parsedRoundness = Number.parseFloat(roundness);
|
||||
return Number.isFinite(parsedRoundness) ? parsedRoundness : 8;
|
||||
}
|
||||
|
||||
return 8;
|
||||
};
|
||||
|
||||
export const getPreviewEmailStyleTokens = (styling: TSurveyStyling): PreviewEmailStyleTokens => {
|
||||
const questionColor =
|
||||
styling.questionColor?.light ?? STYLE_DEFAULTS.questionColor?.light ?? COLOR_DEFAULTS.questionColor;
|
||||
const brandColor =
|
||||
styling.brandColor?.light ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
|
||||
const inputTextColor = styling.inputTextColor?.light ?? questionColor;
|
||||
const inputBackgroundColor =
|
||||
styling.inputBgColor?.light ??
|
||||
styling.inputColor?.light ??
|
||||
STYLE_DEFAULTS.inputColor?.light ??
|
||||
COLOR_DEFAULTS.inputColor;
|
||||
const inputPlaceholderColor = mixColor(inputTextColor, "#ffffff", 0.3);
|
||||
const inputPlaceholderOpacity = getPreviewOpacity(
|
||||
styling.inputPlaceholderOpacity,
|
||||
STYLE_DEFAULTS.inputPlaceholderOpacity ?? 0.5
|
||||
);
|
||||
const optionBackgroundColor =
|
||||
styling.optionBgColor?.light ??
|
||||
styling.inputColor?.light ??
|
||||
STYLE_DEFAULTS.optionBgColor?.light ??
|
||||
COLOR_DEFAULTS.inputColor;
|
||||
const buttonBackgroundColor = styling.buttonBgColor?.light ?? brandColor;
|
||||
const previewRoundness = getPreviewRoundness(styling.roundness);
|
||||
const signatureColor = isLight(questionColor)
|
||||
? mixColor(questionColor, "#000000", 0.2)
|
||||
: mixColor(questionColor, "#ffffff", 0.2);
|
||||
|
||||
return {
|
||||
accentBackgroundColor: styling.accentBgColor?.light ?? mixColor(brandColor, "#ffffff", 0.8),
|
||||
brandColor,
|
||||
buttonBackgroundColor,
|
||||
buttonBorderRadius: getPreviewDimension(
|
||||
styling.buttonBorderRadius,
|
||||
STYLE_DEFAULTS.buttonBorderRadius ?? previewRoundness
|
||||
),
|
||||
buttonFontSize: getPreviewDimension(styling.buttonFontSize, STYLE_DEFAULTS.buttonFontSize),
|
||||
buttonFontWeight: getPreviewFontWeight(styling.buttonFontWeight, STYLE_DEFAULTS.buttonFontWeight),
|
||||
buttonPaddingX: getPreviewDimension(styling.buttonPaddingX, STYLE_DEFAULTS.buttonPaddingX),
|
||||
buttonPaddingY: getPreviewDimension(styling.buttonPaddingY, STYLE_DEFAULTS.buttonPaddingY),
|
||||
buttonTextColor:
|
||||
styling.buttonTextColor?.light ?? (isLight(buttonBackgroundColor) ? "#0f172a" : "#ffffff"),
|
||||
cardBackgroundColor:
|
||||
styling.cardBackgroundColor?.light ??
|
||||
STYLE_DEFAULTS.cardBackgroundColor?.light ??
|
||||
COLOR_DEFAULTS.cardBackgroundColor,
|
||||
cardBorderColor:
|
||||
styling.cardBorderColor?.light ??
|
||||
STYLE_DEFAULTS.cardBorderColor?.light ??
|
||||
COLOR_DEFAULTS.cardBorderColor,
|
||||
elementDescriptionColor: styling.elementDescriptionColor?.light ?? questionColor,
|
||||
elementDescriptionFontSize: getPreviewDimension(
|
||||
styling.elementDescriptionFontSize,
|
||||
STYLE_DEFAULTS.elementDescriptionFontSize
|
||||
),
|
||||
elementDescriptionFontWeight: getPreviewFontWeight(
|
||||
styling.elementDescriptionFontWeight,
|
||||
STYLE_DEFAULTS.elementDescriptionFontWeight
|
||||
),
|
||||
elementHeadlineColor: styling.elementHeadlineColor?.light ?? questionColor,
|
||||
elementHeadlineFontSize: getPreviewDimension(
|
||||
styling.elementHeadlineFontSize,
|
||||
STYLE_DEFAULTS.elementHeadlineFontSize
|
||||
),
|
||||
elementHeadlineFontWeight: getPreviewFontWeight(
|
||||
styling.elementHeadlineFontWeight,
|
||||
STYLE_DEFAULTS.elementHeadlineFontWeight
|
||||
),
|
||||
elementUpperLabelColor: styling.elementUpperLabelColor?.light ?? questionColor,
|
||||
elementUpperLabelFontSize: getPreviewDimension(
|
||||
styling.elementUpperLabelFontSize,
|
||||
STYLE_DEFAULTS.elementUpperLabelFontSize
|
||||
),
|
||||
elementUpperLabelFontWeight: getPreviewFontWeight(
|
||||
styling.elementUpperLabelFontWeight,
|
||||
STYLE_DEFAULTS.elementUpperLabelFontWeight
|
||||
),
|
||||
fontFamily: styling.fontFamily ?? SURVEY_EMAIL_FONT_FAMILY,
|
||||
inputBackgroundColor,
|
||||
inputBorderColor:
|
||||
styling.inputBorderColor?.light ??
|
||||
STYLE_DEFAULTS.inputBorderColor?.light ??
|
||||
COLOR_DEFAULTS.inputBorderColor,
|
||||
inputBorderRadius: getPreviewDimension(
|
||||
styling.inputBorderRadius,
|
||||
STYLE_DEFAULTS.inputBorderRadius ?? previewRoundness
|
||||
),
|
||||
inputColor: styling.inputColor?.light ?? STYLE_DEFAULTS.inputColor?.light ?? COLOR_DEFAULTS.inputColor,
|
||||
inputFontSize: getPreviewDimension(styling.inputFontSize, STYLE_DEFAULTS.inputFontSize),
|
||||
inputHeight: getPreviewDimension(styling.inputHeight, STYLE_DEFAULTS.inputHeight),
|
||||
inputPaddingX: getPreviewDimension(styling.inputPaddingX, STYLE_DEFAULTS.inputPaddingX),
|
||||
inputPaddingY: getPreviewDimension(styling.inputPaddingY, STYLE_DEFAULTS.inputPaddingY),
|
||||
inputPlaceholderEmailColor: mixColor(
|
||||
inputPlaceholderColor,
|
||||
inputBackgroundColor,
|
||||
1 - inputPlaceholderOpacity
|
||||
),
|
||||
inputShadow: styling.inputShadow ?? STYLE_DEFAULTS.inputShadow ?? "",
|
||||
inputTextColor,
|
||||
optionBackgroundColor,
|
||||
optionBorderColor:
|
||||
styling.optionBorderColor?.light ??
|
||||
styling.inputBorderColor?.light ??
|
||||
STYLE_DEFAULTS.optionBorderColor?.light ??
|
||||
COLOR_DEFAULTS.inputBorderColor,
|
||||
optionBorderRadius: getPreviewDimension(
|
||||
styling.optionBorderRadius,
|
||||
STYLE_DEFAULTS.optionBorderRadius ?? previewRoundness
|
||||
),
|
||||
optionFontSize: getPreviewDimension(styling.optionFontSize, STYLE_DEFAULTS.optionFontSize),
|
||||
optionLabelColor: styling.optionLabelColor?.light ?? questionColor,
|
||||
optionPaddingX: getPreviewDimension(styling.optionPaddingX, STYLE_DEFAULTS.optionPaddingX),
|
||||
optionPaddingY: getPreviewDimension(styling.optionPaddingY, STYLE_DEFAULTS.optionPaddingY),
|
||||
questionColor,
|
||||
roundness: previewRoundness,
|
||||
signatureColor,
|
||||
};
|
||||
};
|
||||
|
||||
const buildSurveyEmailUrl = (surveyUrl: string, entries: Array<[string, string]>): string => {
|
||||
const url = new URL(surveyUrl);
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const getPreviewSurveyUrl = (surveyUrl: string): string =>
|
||||
buildSurveyEmailUrl(surveyUrl, [["preview", "true"]]);
|
||||
|
||||
export const getPrefilledSurveyUrl = (surveyUrl: string, questionId: string, value: string): string =>
|
||||
buildSurveyEmailUrl(surveyUrl, [
|
||||
["preview", "true"],
|
||||
[questionId, value],
|
||||
["skipPrefilled", "true"],
|
||||
]);
|
||||
|
||||
const getChoiceBlockStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
|
||||
...getForcedBackgroundStyle(styleTokens.optionBackgroundColor),
|
||||
border: importantStyle(`1px solid ${styleTokens.optionBorderColor}`),
|
||||
});
|
||||
|
||||
const getChoiceTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
|
||||
...getForcedColorStyle(styleTokens.optionLabelColor),
|
||||
});
|
||||
|
||||
export const getChoiceCardStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
|
||||
...getChoiceBlockStyle(styleTokens),
|
||||
...getChoiceTextStyle(styleTokens),
|
||||
textDecoration: "none",
|
||||
});
|
||||
|
||||
const getFieldPlaceholderStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
|
||||
...getForcedBackgroundStyle(styleTokens.inputBackgroundColor),
|
||||
...getForcedColorStyle(styleTokens.inputPlaceholderEmailColor),
|
||||
border: importantStyle(`1px solid ${styleTokens.inputBorderColor}`),
|
||||
});
|
||||
|
||||
export const getInputTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
|
||||
...getForcedColorStyle(styleTokens.inputTextColor),
|
||||
});
|
||||
|
||||
export const getFieldLabelStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
|
||||
...getForcedColorStyle(styleTokens.questionColor),
|
||||
fontFamily: styleTokens.fontFamily,
|
||||
});
|
||||
|
||||
export const getSecondaryButtonStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
|
||||
border: importantStyle(`1px solid ${styleTokens.inputBorderColor}`),
|
||||
...getForcedColorStyle(styleTokens.questionColor),
|
||||
});
|
||||
|
||||
export const getPrimaryButtonStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
|
||||
...getSecondaryButtonStyle(styleTokens),
|
||||
...getForcedBackgroundStyle(styleTokens.buttonBackgroundColor),
|
||||
border: importantStyle(`1px solid ${styleTokens.buttonBackgroundColor}`),
|
||||
...getForcedColorStyle(styleTokens.buttonTextColor),
|
||||
});
|
||||
|
||||
export const getPreviewAccentColor = (token?: string): string | undefined =>
|
||||
token ? EMAIL_PREVIEW_ACCENT_COLORS[token as keyof typeof EMAIL_PREVIEW_ACCENT_COLORS] : undefined;
|
||||
|
||||
const getConnectedScaleBorderRadius = (
|
||||
styleTokens: PreviewEmailStyleTokens,
|
||||
optionIndex: number,
|
||||
optionCount: number
|
||||
): string => {
|
||||
if (optionCount <= 1) return styleTokens.inputBorderRadius;
|
||||
|
||||
if (optionIndex === 0) return `${styleTokens.inputBorderRadius} 0 0 ${styleTokens.inputBorderRadius}`;
|
||||
if (optionIndex === optionCount - 1)
|
||||
return `0 ${styleTokens.inputBorderRadius} ${styleTokens.inputBorderRadius} 0`;
|
||||
|
||||
return "0";
|
||||
};
|
||||
|
||||
const getScaleLineHeight = (height: string, hasColorStrip: boolean): string => {
|
||||
if (!hasColorStrip || !height.endsWith("px")) return height;
|
||||
|
||||
const numericHeight = Number.parseFloat(height);
|
||||
|
||||
if (!Number.isFinite(numericHeight)) return height;
|
||||
|
||||
return `${Math.max(numericHeight - 6, 0).toString()}px`;
|
||||
};
|
||||
|
||||
export const getScaleOptionStyle = ({
|
||||
styleTokens,
|
||||
borderTopColor,
|
||||
height,
|
||||
isConnected = false,
|
||||
isCompact = false,
|
||||
isTransparent = false,
|
||||
optionCount = 0,
|
||||
optionIndex = 0,
|
||||
}: {
|
||||
styleTokens: PreviewEmailStyleTokens;
|
||||
borderTopColor?: string;
|
||||
height?: string;
|
||||
isConnected?: boolean;
|
||||
isCompact?: boolean;
|
||||
isTransparent?: boolean;
|
||||
optionCount?: number;
|
||||
optionIndex?: number;
|
||||
}): CSSProperties => {
|
||||
const optionHeight = height ?? (isCompact ? "40px" : "46px");
|
||||
const optionLineHeight = getScaleLineHeight(optionHeight, Boolean(borderTopColor));
|
||||
const shouldConnect = isConnected && optionCount > 0;
|
||||
|
||||
return {
|
||||
...getForcedBackgroundStyle(isTransparent ? "transparent" : styleTokens.inputBackgroundColor),
|
||||
border: importantStyle(
|
||||
isTransparent ? "1px solid transparent" : `1px solid ${styleTokens.inputBorderColor}`
|
||||
),
|
||||
borderRadius: shouldConnect
|
||||
? getConnectedScaleBorderRadius(styleTokens, optionIndex, optionCount)
|
||||
: styleTokens.inputBorderRadius,
|
||||
...(shouldConnect && optionIndex > 0 ? { borderLeft: importantStyle("0") } : {}),
|
||||
...getForcedColorStyle(styleTokens.inputTextColor),
|
||||
height: optionHeight,
|
||||
lineHeight: optionLineHeight,
|
||||
...(borderTopColor ? { borderTop: importantStyle(`6px solid ${borderTopColor}`) } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getLightModeTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
|
||||
...getForcedColorStyle(styleTokens.elementHeadlineColor),
|
||||
fontFamily: styleTokens.fontFamily,
|
||||
fontSize: styleTokens.elementHeadlineFontSize,
|
||||
fontWeight: styleTokens.elementHeadlineFontWeight,
|
||||
});
|
||||
|
||||
export const getHelperLabelTextStyle = (styleTokens: PreviewEmailStyleTokens): CSSProperties => ({
|
||||
...getForcedColorStyle(styleTokens.elementUpperLabelColor),
|
||||
fontFamily: styleTokens.fontFamily,
|
||||
fontSize: styleTokens.elementUpperLabelFontSize,
|
||||
fontWeight: styleTokens.elementUpperLabelFontWeight,
|
||||
});
|
||||
|
||||
export const getCenteredPlaceholderStyle = (
|
||||
styleTokens: PreviewEmailStyleTokens,
|
||||
overrides?: CSSProperties
|
||||
): CSSProperties => {
|
||||
const baseStyle: CSSProperties = {
|
||||
...getFieldPlaceholderStyle(styleTokens),
|
||||
};
|
||||
|
||||
return overrides ? { ...baseStyle, ...overrides } : baseStyle;
|
||||
};
|
||||
|
||||
export const getCenteredPlaceholderTextStyle = (
|
||||
styleTokens: PreviewEmailStyleTokens,
|
||||
overrides?: CSSProperties
|
||||
): CSSProperties => {
|
||||
const baseStyle: CSSProperties = {
|
||||
...getForcedColorStyle(styleTokens.inputPlaceholderEmailColor),
|
||||
};
|
||||
|
||||
return overrides ? { ...baseStyle, ...overrides } : baseStyle;
|
||||
};
|
||||
|
||||
export const getInputShellStyle = (
|
||||
styleTokens: PreviewEmailStyleTokens,
|
||||
overrides?: CSSProperties
|
||||
): CSSProperties => ({
|
||||
...getCenteredPlaceholderStyle(styleTokens),
|
||||
boxSizing: "border-box",
|
||||
borderRadius: styleTokens.inputBorderRadius,
|
||||
display: "block",
|
||||
fontFamily: styleTokens.fontFamily,
|
||||
fontSize: styleTokens.inputFontSize,
|
||||
lineHeight: "20px",
|
||||
overflow: "hidden",
|
||||
padding: `${styleTokens.inputPaddingY} ${styleTokens.inputPaddingX}`,
|
||||
width: "100%",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const getInputShellLinkStyle = (
|
||||
styleTokens: PreviewEmailStyleTokens,
|
||||
overrides?: CSSProperties
|
||||
): CSSProperties => ({
|
||||
...getInputTextStyle(styleTokens),
|
||||
display: "block",
|
||||
fontFamily: styleTokens.fontFamily,
|
||||
fontSize: styleTokens.inputFontSize,
|
||||
fontWeight: 400,
|
||||
lineHeight: "20px",
|
||||
textDecoration: importantStyle("none"),
|
||||
width: "100%",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const getScaleColumnStyle = (optionCount: number): CSSProperties => ({
|
||||
width: `${(100 / optionCount).toFixed(4)}%`,
|
||||
});
|
||||
|
||||
export const getChoiceMarkerStyle = (
|
||||
marker: PreviewMarkerVariant,
|
||||
styleTokens: PreviewEmailStyleTokens
|
||||
): CSSProperties => ({
|
||||
...(marker === "ranking"
|
||||
? {
|
||||
background: importantStyle("transparent"),
|
||||
backgroundColor: importantStyle("transparent"),
|
||||
colorScheme: FORCE_LIGHT_COLOR_SCHEME,
|
||||
}
|
||||
: getForcedBackgroundStyle("#ffffff")),
|
||||
border: importantStyle(
|
||||
`${marker === "ranking" ? "1px dashed" : "1px solid"} ${
|
||||
marker === "ranking" ? styleTokens.brandColor : styleTokens.inputBorderColor
|
||||
}`
|
||||
),
|
||||
});
|
||||
|
||||
export const getChoiceMarkerClassName = (marker: PreviewMarkerVariant, withLabelGap = true): string =>
|
||||
`${withLabelGap ? "mr-3 " : ""}${CHOICE_MARKER_CLASSNAME} ${
|
||||
marker === "checkbox" ? "rounded" : "rounded-full"
|
||||
}`;
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import {
|
||||
@@ -32,7 +31,6 @@ const ZCreateWebhookAction = z.object({
|
||||
export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreateWebhookAction).action(
|
||||
withAuditLogging("created", "webhook", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
@@ -44,7 +42,7 @@ export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreate
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId,
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -55,19 +53,6 @@ export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreate
|
||||
);
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.newObject = parsedInput.webhookInput;
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "webhook",
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: parsedInput.environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
|
||||
return webhook;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
@@ -48,34 +47,10 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
});
|
||||
}
|
||||
|
||||
const newProject = await createProject(newOrganization.id, {
|
||||
await createProject(newOrganization.id, {
|
||||
name: "My Project",
|
||||
});
|
||||
|
||||
groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name });
|
||||
groupIdentifyPostHog("workspace", newProject.id, { name: newProject.name });
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"organization_created",
|
||||
{
|
||||
organization_id: newOrganization.id,
|
||||
is_first_org: false,
|
||||
},
|
||||
{ organizationId: newOrganization.id, workspaceId: newProject.id }
|
||||
);
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"workspace_created",
|
||||
{
|
||||
organization_id: newOrganization.id,
|
||||
workspace_id: newProject.id,
|
||||
name: newProject.name,
|
||||
},
|
||||
{ organizationId: newOrganization.id, workspaceId: newProject.id }
|
||||
);
|
||||
|
||||
const updatedNotificationSettings: TUserNotificationSettings = {
|
||||
...ctx.user.notificationSettings,
|
||||
alert: {
|
||||
|
||||
@@ -328,15 +328,10 @@ export const inviteUserAction = authenticatedActionClient.inputSchema(ZInviteUse
|
||||
await sendInviteMemberEmail(inviteId, parsedInput.email, ctx.user.name ?? "", parsedInput.name ?? "");
|
||||
}
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"team_member_invited",
|
||||
{
|
||||
organization_id: parsedInput.organizationId,
|
||||
invitee_role: parsedInput.role,
|
||||
},
|
||||
{ organizationId: parsedInput.organizationId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "team_member_invited", {
|
||||
organization_id: parsedInput.organizationId,
|
||||
invitee_role: parsedInput.role,
|
||||
});
|
||||
|
||||
return inviteId;
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/typ
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getProject } from "@/lib/project/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -73,35 +72,6 @@ export const updateProjectAction = authenticatedActionClient.inputSchema(ZUpdate
|
||||
const result = await updateProject(parsedInput.projectId, parsedInput.data);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
const groupContext = { organizationId, workspaceId: parsedInput.projectId };
|
||||
|
||||
if (oldObject?.linkSurveyBranding === true && parsedInput.data.linkSurveyBranding === false) {
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"remove_branding_enabled",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: parsedInput.projectId,
|
||||
branding_type: "link",
|
||||
},
|
||||
groupContext
|
||||
);
|
||||
}
|
||||
|
||||
if (oldObject?.inAppSurveyBranding === true && parsedInput.data.inAppSurveyBranding === false) {
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"remove_branding_enabled",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: parsedInput.projectId,
|
||||
branding_type: "in_app",
|
||||
},
|
||||
groupContext
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getHasNoOrganizations, getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const FreshInstanceLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const isFreshInstance = await getIsFreshInstance();
|
||||
|
||||
if (!isFreshInstance) {
|
||||
const hasNoOrganizations = await getHasNoOrganizations();
|
||||
|
||||
if (hasNoOrganizations) {
|
||||
if (session) {
|
||||
return redirect("/setup/organization/create");
|
||||
}
|
||||
|
||||
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
|
||||
}
|
||||
|
||||
if (session || !isFreshInstance) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (session) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getHasNoOrganizations } from "@/lib/instance/service";
|
||||
import { gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -29,7 +29,7 @@ export const CreateOrganizationPage = async () => {
|
||||
return <ClientLogout />;
|
||||
}
|
||||
|
||||
const hasNoOrganizations = await getHasNoOrganizations();
|
||||
const hasNoOrganizations = await gethasNoOrganizations();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const userOrganizations = await getOrganizationsByUserId(session.user.id);
|
||||
|
||||
|
||||
@@ -66,26 +66,18 @@ export const createSurveyAction = authenticatedActionClient.inputSchema(ZCreateS
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||
const result = await createSurvey(parsedInput.environmentId, parsedInput.surveyBody);
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"survey_created",
|
||||
{
|
||||
survey_id: result.id,
|
||||
survey_type: result.type,
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: parsedInput.environmentId,
|
||||
question_count: result.questions?.length ?? 0,
|
||||
created_from: parsedInput.createdFrom ?? "template",
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "survey_created", {
|
||||
survey_id: result.id,
|
||||
survey_type: result.type,
|
||||
organization_id: organizationId,
|
||||
question_count: result.questions?.length ?? 0,
|
||||
created_from: parsedInput.createdFrom ?? "template",
|
||||
});
|
||||
|
||||
return result;
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurvey, TSurveyVariable, ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import { POSTHOG_KEY, UNSPLASH_ACCESS_KEY, UNSPLASH_ALLOWED_DOMAINS } from "@/lib/constants";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
@@ -28,138 +28,6 @@ import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { getProject, getProjectLanguages } from "./lib/project";
|
||||
|
||||
type SurveyEditDiffContext = {
|
||||
userId: string;
|
||||
surveyId: string;
|
||||
organizationId: string;
|
||||
workspaceId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
const captureSurveyEditDiffEvents = (
|
||||
oldSurvey: TSurvey | null,
|
||||
newSurvey: TSurvey,
|
||||
context: SurveyEditDiffContext
|
||||
): void => {
|
||||
if (!oldSurvey) return;
|
||||
|
||||
const groupContext = { organizationId: context.organizationId, workspaceId: context.workspaceId };
|
||||
const baseProps = {
|
||||
organization_id: context.organizationId,
|
||||
workspace_id: context.workspaceId,
|
||||
environment_id: context.environmentId,
|
||||
survey_id: context.surveyId,
|
||||
};
|
||||
|
||||
// hidden_field_added
|
||||
const oldFieldIds = new Set(oldSurvey.hiddenFields?.fieldIds ?? []);
|
||||
const newFieldIds = newSurvey.hiddenFields?.fieldIds ?? [];
|
||||
const addedFieldIds = newFieldIds.filter((id) => !oldFieldIds.has(id));
|
||||
if (addedFieldIds.length > 0) {
|
||||
capturePostHogEvent(
|
||||
context.userId,
|
||||
"hidden_field_added",
|
||||
{ ...baseProps, field_count: newFieldIds.length },
|
||||
groupContext
|
||||
);
|
||||
}
|
||||
|
||||
// conditional_logic_added (per block)
|
||||
const oldBlocks = oldSurvey.blocks ?? [];
|
||||
const newBlocks = newSurvey.blocks ?? [];
|
||||
const oldBlockLogic = new Map<string, number>(
|
||||
oldBlocks.map((b) => [b.id, (b.logic?.length ?? 0) + (b.logicFallback ? 1 : 0)])
|
||||
);
|
||||
for (const block of newBlocks) {
|
||||
const newLogicCount = (block.logic?.length ?? 0) + (block.logicFallback ? 1 : 0);
|
||||
const oldLogicCount = oldBlockLogic.get(block.id) ?? 0;
|
||||
if (newLogicCount > oldLogicCount) {
|
||||
capturePostHogEvent(
|
||||
context.userId,
|
||||
"conditional_logic_added",
|
||||
{ ...baseProps, question_id: block.id },
|
||||
groupContext
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// variable_created
|
||||
const oldVariableIds = new Set((oldSurvey.variables ?? []).map((v: TSurveyVariable) => v.id));
|
||||
for (const variable of newSurvey.variables ?? []) {
|
||||
if (!oldVariableIds.has(variable.id)) {
|
||||
capturePostHogEvent(
|
||||
context.userId,
|
||||
"variable_created",
|
||||
{ ...baseProps, variable_type: variable.type },
|
||||
groupContext
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// survey_language_enabled / survey_language_added
|
||||
const oldLanguages = oldSurvey.languages ?? [];
|
||||
const newLanguages = newSurvey.languages ?? [];
|
||||
const oldLanguageCodes = new Set(oldLanguages.map((l) => l.language.code));
|
||||
const addedLanguages = newLanguages.filter((l) => !oldLanguageCodes.has(l.language.code));
|
||||
|
||||
if (addedLanguages.length > 0) {
|
||||
const wasMultiLangBefore = oldLanguages.length > 1;
|
||||
let currentCount = oldLanguages.length;
|
||||
|
||||
if (!wasMultiLangBefore) {
|
||||
const [first, ...rest] = addedLanguages;
|
||||
capturePostHogEvent(
|
||||
context.userId,
|
||||
"survey_language_enabled",
|
||||
{ ...baseProps, language_code: first.language.code, existing_language_count: currentCount },
|
||||
groupContext
|
||||
);
|
||||
currentCount++;
|
||||
for (const lang of rest) {
|
||||
capturePostHogEvent(
|
||||
context.userId,
|
||||
"survey_language_added",
|
||||
{
|
||||
...baseProps,
|
||||
language_code: lang.language.code,
|
||||
existing_language_count: currentCount,
|
||||
},
|
||||
groupContext
|
||||
);
|
||||
currentCount++;
|
||||
}
|
||||
} else {
|
||||
for (const lang of addedLanguages) {
|
||||
capturePostHogEvent(
|
||||
context.userId,
|
||||
"survey_language_added",
|
||||
{
|
||||
...baseProps,
|
||||
language_code: lang.language.code,
|
||||
existing_language_count: currentCount,
|
||||
},
|
||||
groupContext
|
||||
);
|
||||
currentCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// follow_up_added
|
||||
const oldFollowUpIds = new Set((oldSurvey.followUps ?? []).map((f) => f.id));
|
||||
const newFollowUps = (newSurvey.followUps ?? []).filter((f) => !f.deleted);
|
||||
for (const followUp of newFollowUps) {
|
||||
if (!oldFollowUpIds.has(followUp.id)) {
|
||||
capturePostHogEvent(
|
||||
context.userId,
|
||||
"follow_up_added",
|
||||
{ ...baseProps, follow_up_id: followUp.id },
|
||||
groupContext
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if survey follow-ups can be added for the given organization.
|
||||
* Grandfathers existing follow-ups (allows keeping them even if the org lost access).
|
||||
@@ -191,7 +59,6 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
|
||||
const survey = parsedInput as TSurvey;
|
||||
|
||||
const organizationId = await getOrganizationIdFromSurveyId(survey.id);
|
||||
const projectId = await getProjectIdFromSurveyId(survey.id);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
@@ -202,7 +69,7 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId,
|
||||
projectId: await getProjectIdFromSurveyId(survey.id),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -233,14 +100,6 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
captureSurveyEditDiffEvents(oldObject, result, {
|
||||
userId: ctx.user.id,
|
||||
surveyId: result.id,
|
||||
organizationId,
|
||||
workspaceId: projectId,
|
||||
environmentId: result.environmentId,
|
||||
});
|
||||
|
||||
revalidatePath(`/environments/${result.environmentId}/surveys/${result.id}`);
|
||||
|
||||
return result;
|
||||
@@ -289,39 +148,23 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
const projectId = await getProjectIdFromSurveyId(parsedInput.id);
|
||||
if (POSTHOG_KEY && result.status !== "draft") {
|
||||
const isPublish = oldObject?.status === "draft" && result.status === "inProgress";
|
||||
|
||||
captureSurveyEditDiffEvents(oldObject, result, {
|
||||
userId: ctx.user.id,
|
||||
surveyId: result.id,
|
||||
organizationId,
|
||||
workspaceId: projectId,
|
||||
environmentId: result.environmentId,
|
||||
});
|
||||
const posthogEventMetadata = {
|
||||
survey_id: result.id,
|
||||
survey_type: result.type,
|
||||
question_count: getElementsFromBlocks(result.blocks).length,
|
||||
organization_id: organizationId,
|
||||
has_targeting: result.segment ? !result.segment.isPrivate : false,
|
||||
language_count: result.languages?.length ?? 0,
|
||||
};
|
||||
|
||||
if (POSTHOG_KEY) {
|
||||
if (result.status !== "draft") {
|
||||
const isPublish = oldObject?.status === "draft" && result.status === "inProgress";
|
||||
|
||||
const posthogEventMetadata = {
|
||||
survey_id: result.id,
|
||||
survey_type: result.type,
|
||||
question_count: getElementsFromBlocks(result.blocks).length,
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: result.environmentId,
|
||||
has_targeting: result.segment ? !result.segment.isPrivate : false,
|
||||
language_count: result.languages?.length ?? 0,
|
||||
};
|
||||
|
||||
const groupContext = { organizationId, workspaceId: projectId };
|
||||
|
||||
if (isPublish) {
|
||||
capturePostHogEvent(ctx.user.id, "survey_published", posthogEventMetadata, groupContext);
|
||||
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata, groupContext);
|
||||
} else {
|
||||
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata, groupContext);
|
||||
}
|
||||
if (isPublish) {
|
||||
capturePostHogEvent(ctx.user.id, "survey_published", posthogEventMetadata);
|
||||
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata);
|
||||
} else {
|
||||
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,27 +335,9 @@ export const createActionClassAction = authenticatedActionClient.inputSchema(ZCr
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const projectId = await getProjectIdFromEnvironmentId(parsedInput.action.environmentId);
|
||||
const result = await createActionClass(parsedInput.action.environmentId, parsedInput.action);
|
||||
ctx.auditLoggingCtx.actionClassId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
const triggerType =
|
||||
parsedInput.action.type === "code" ? "codeAction" : (parsedInput.action.noCodeConfig?.type ?? "noCode");
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"action_class_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: parsedInput.action.environmentId,
|
||||
type: parsedInput.action.type,
|
||||
trigger_type: triggerType,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ActionClass, Prisma } from "@prisma/client";
|
||||
import { ActionClass } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { createActionClass } from "./action-class";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -99,19 +99,14 @@ describe("createActionClass", () => {
|
||||
expect(result).toEqual(createdAction);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError for unique constraint violation", async () => {
|
||||
const prismaError = Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: { target: ["name"] } }
|
||||
);
|
||||
test("should throw DatabaseError for unique constraint violation", async () => {
|
||||
const prismaError = {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
meta: { target: ["name"] },
|
||||
};
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createActionClass(mockEnvironmentId, mockCodeActionInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
await expect(createActionClass(mockEnvironmentId, mockCodeActionInput)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError for other database errors", async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ActionClass, Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const createActionClass = async (
|
||||
environmentId: string,
|
||||
@@ -32,7 +32,7 @@ export const createActionClass = async (
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
const targetField = (error.meta?.target as string[] | undefined)?.[0];
|
||||
throw new UniqueConstraintError(
|
||||
throw new DatabaseError(
|
||||
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { CustomScriptsInjector } from "@/modules/survey/link/components/custom-scripts-injector";
|
||||
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
|
||||
@@ -134,13 +133,11 @@ export const SurveyClientWrapper = ({
|
||||
}
|
||||
setResponseData({});
|
||||
};
|
||||
const jsSurvey = useMemo(() => toJsEnvironmentStateSurvey(survey), [survey]);
|
||||
|
||||
// Determine text direction based on language code for logo positioning only
|
||||
// which checks both language code and survey content. This is only for logo UI positioning.
|
||||
const logoDir = useMemo(() => {
|
||||
return isRTLLanguage(jsSurvey, languageCode) ? "rtl" : "auto";
|
||||
}, [languageCode, jsSurvey]);
|
||||
return isRTLLanguage(survey, languageCode) ? "rtl" : "auto";
|
||||
}, [languageCode, survey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -172,7 +169,7 @@ export const SurveyClientWrapper = ({
|
||||
appUrl={publicDomain}
|
||||
environmentId={survey.environmentId}
|
||||
isPreviewMode={isPreview}
|
||||
survey={jsSurvey}
|
||||
survey={survey}
|
||||
styling={styling}
|
||||
languageCode={languageCode}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
|
||||
@@ -33,14 +33,6 @@ export const SurveyInactive = async ({
|
||||
"link expired": t("c.link_expired_description"),
|
||||
};
|
||||
|
||||
const headings = {
|
||||
paused: t("s.paused_heading"),
|
||||
completed: t("s.completed_heading"),
|
||||
"link invalid": t("s.this_looks_fishy"),
|
||||
"response submitted": t("s.survey_already_answered_heading"),
|
||||
"link expired": t("c.link_expired_heading"),
|
||||
};
|
||||
|
||||
const showCTA =
|
||||
status !== "link invalid" &&
|
||||
status !== "link expired" &&
|
||||
@@ -55,7 +47,7 @@ export const SurveyInactive = async ({
|
||||
<h1 className="text-4xl font-bold text-slate-800">
|
||||
{(status === "completed" || status === "link expired") && surveyClosedMessage
|
||||
? surveyClosedMessage.heading
|
||||
: headings[status]}
|
||||
: `${t("common.survey")} ${status}.`}
|
||||
</h1>
|
||||
<p className="text-lg leading-10 text-slate-500">
|
||||
{status === "completed" && surveyClosedMessage
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getSurveysUsingGivenLanguage,
|
||||
updateLanguage,
|
||||
} from "@/lib/language/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import {
|
||||
@@ -51,18 +50,6 @@ export const createLanguageAction = authenticatedActionClient.inputSchema(ZCreat
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.languageId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"workspace_language_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: parsedInput.projectId,
|
||||
language_code: result.code,
|
||||
},
|
||||
{ organizationId, workspaceId: parsedInput.projectId }
|
||||
);
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurvey, TSurveyLanguage, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { ClientLogo } from "@/modules/ui/components/client-logo";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -273,7 +272,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey(survey)}
|
||||
survey={survey}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
@@ -304,7 +303,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
languageCode={languageCode}
|
||||
responseCount={42}
|
||||
@@ -389,7 +388,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey(survey)}
|
||||
survey={survey}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
@@ -424,7 +423,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Fragment, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { ClientLogo } from "@/modules/ui/components/client-logo";
|
||||
import { MediaBackground } from "@/modules/ui/components/media-background";
|
||||
import { Modal } from "@/modules/ui/components/preview-survey/components/modal";
|
||||
@@ -179,7 +178,7 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "app" })}
|
||||
survey={{ ...survey, type: "app" }}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
@@ -206,7 +205,7 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { type Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
EMBED_SURVEY_PREVIEW_CHOICE_IDS,
|
||||
EMBED_SURVEY_PREVIEW_HEADLINE,
|
||||
EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER,
|
||||
EMBED_SURVEY_PREVIEW_QUESTION_ID,
|
||||
createEmbedSurveyPreviewEmailSurvey,
|
||||
} from "@/modules/email/fixtures/embed-survey-preview-email-fixture";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
/**
|
||||
* Manual QA for changes touching the survey email preview:
|
||||
* 1. Check the React Email preview at http://localhost:3456/preview/survey/embed-survey-preview-email.
|
||||
* 2. Check the in-app summary email preview for the seeded kitchen sink survey.
|
||||
* 3. Send one preview email and compare it in new Outlook against the in-app preview.
|
||||
*/
|
||||
|
||||
const getUserContext = async (email: string) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
memberships: {
|
||||
take: 1,
|
||||
select: {
|
||||
organization: {
|
||||
select: {
|
||||
projects: {
|
||||
take: 1,
|
||||
select: {
|
||||
environments: {
|
||||
where: {
|
||||
type: "development",
|
||||
},
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const environmentId = user?.memberships[0]?.organization.projects[0]?.environments[0]?.id;
|
||||
if (!user?.id || !environmentId) {
|
||||
throw new Error(`Unable to resolve user context for ${email}`);
|
||||
}
|
||||
|
||||
return {
|
||||
environmentId,
|
||||
userId: user.id,
|
||||
};
|
||||
};
|
||||
|
||||
const createSurveySeed = async (
|
||||
environmentId: string,
|
||||
userId: string,
|
||||
name: string,
|
||||
type: TSurveyElementTypeEnum = TSurveyElementTypeEnum.MultipleChoiceMulti
|
||||
) => {
|
||||
const surveyFixture = createEmbedSurveyPreviewEmailSurvey(type);
|
||||
const blocks = surveyFixture.blocks as unknown as Prisma.InputJsonValue[];
|
||||
const endings = surveyFixture.endings as unknown as Prisma.InputJsonValue[];
|
||||
const variables = surveyFixture.variables as unknown as Prisma.InputJsonValue[];
|
||||
|
||||
return prisma.survey.create({
|
||||
data: {
|
||||
environmentId,
|
||||
createdBy: userId,
|
||||
name,
|
||||
status: "inProgress",
|
||||
type: "link",
|
||||
welcomeCard: surveyFixture.welcomeCard,
|
||||
blocks,
|
||||
endings,
|
||||
hiddenFields: surveyFixture.hiddenFields,
|
||||
styling: surveyFixture.styling,
|
||||
surveyClosedMessage: surveyFixture.surveyClosedMessage,
|
||||
isBackButtonHidden: surveyFixture.isBackButtonHidden,
|
||||
isAutoProgressingEnabled: surveyFixture.isAutoProgressingEnabled,
|
||||
isCaptureIpEnabled: surveyFixture.isCaptureIpEnabled,
|
||||
isVerifyEmailEnabled: surveyFixture.isVerifyEmailEnabled,
|
||||
isSingleResponsePerEmailEnabled: surveyFixture.isSingleResponsePerEmailEnabled,
|
||||
variables,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test.describe("Survey Email Preview", () => {
|
||||
test("renders the real summary email preview with styled per-option links", async ({ page, users }) => {
|
||||
const timestamp = Date.now();
|
||||
const email = `survey-email-preview-${timestamp}@example.com`;
|
||||
const user = await users.create({
|
||||
email,
|
||||
name: `survey-email-preview-${timestamp}`,
|
||||
projectName: "Email Preview Workspace",
|
||||
});
|
||||
|
||||
await user.login();
|
||||
await expect(page).toHaveURL(/\/environments\/[^/]+/);
|
||||
|
||||
const { environmentId, userId } = await getUserContext(email);
|
||||
const survey = await createSurveySeed(environmentId, userId, `Email Preview Survey ${timestamp}`);
|
||||
|
||||
await page.goto(`/environments/${environmentId}/surveys/${survey.id}/summary`);
|
||||
await page.getByRole("button", { name: "Share survey" }).click();
|
||||
await page.getByRole("button", { name: "Email embed" }).click();
|
||||
|
||||
const previewShell = page.getByTestId("survey-email-preview-shell");
|
||||
const previewFrameElement = page.getByTestId("survey-email-preview-frame");
|
||||
const previewFrame = page.frameLocator('[data-testid="survey-email-preview-frame"]');
|
||||
|
||||
await expect(previewShell).toBeVisible();
|
||||
await expect(previewFrameElement).toBeVisible();
|
||||
await expect(previewFrame.getByText(EMBED_SURVEY_PREVIEW_HEADLINE)).toBeVisible();
|
||||
await expect(previewFrame.getByText("Apples", { exact: true })).toBeVisible();
|
||||
await expect(previewFrame.getByText("Bananas", { exact: true })).toBeVisible();
|
||||
await expect(previewFrame.getByText("Pineapples", { exact: true })).toBeVisible();
|
||||
|
||||
const firstChoiceLink = previewFrame.locator(
|
||||
`a[href*="skipPrefilled=true"][href*="${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${encodeURIComponent(EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples)}"]`
|
||||
);
|
||||
const firstChoiceMarker = firstChoiceLink.locator('span[style*="border"]');
|
||||
|
||||
await expect(previewFrame.locator(`a[href*="${EMBED_SURVEY_PREVIEW_QUESTION_ID}="]`)).toHaveCount(3);
|
||||
await expect(firstChoiceLink).toHaveCount(1);
|
||||
await expect(firstChoiceMarker).toHaveCount(1);
|
||||
await expect(firstChoiceMarker).toHaveCSS("height", "16px");
|
||||
await expect(firstChoiceMarker).toHaveCSS("border-top-left-radius", "4px");
|
||||
await expect(firstChoiceLink).toContainText("Apples");
|
||||
await expect(firstChoiceLink).toHaveCSS("background-color", "rgb(243, 244, 246)");
|
||||
await expect(firstChoiceLink).toHaveCSS("border-top-left-radius", "8px");
|
||||
await expect(firstChoiceLink).toHaveCSS("font-family", /Inter/);
|
||||
await expect(firstChoiceLink).toHaveCSS("padding-top", "16px");
|
||||
await expect(firstChoiceLink).toHaveAttribute(
|
||||
"href",
|
||||
new RegExp(`${EMBED_SURVEY_PREVIEW_QUESTION_ID}=${EMBED_SURVEY_PREVIEW_CHOICE_IDS.apples}`)
|
||||
);
|
||||
await expect(firstChoiceLink).toHaveAttribute("href", /preview=true/);
|
||||
await expect(firstChoiceLink).toHaveAttribute("href", /skipPrefilled=true/);
|
||||
await expect(firstChoiceLink).toHaveAttribute("target", "_blank");
|
||||
|
||||
const poweredByLink = previewFrame.getByRole("link", { name: "Powered by Formbricks" });
|
||||
await expect(poweredByLink).toHaveAttribute("href", "https://formbricks.com?utm_source=email_branding");
|
||||
});
|
||||
|
||||
test("keeps non-option email previews clickable in the summary modal", async ({ page, users }) => {
|
||||
const timestamp = Date.now();
|
||||
const email = `survey-email-open-text-${timestamp}@example.com`;
|
||||
const user = await users.create({
|
||||
email,
|
||||
name: `survey-email-open-text-${timestamp}`,
|
||||
projectName: "Email Preview Workspace",
|
||||
});
|
||||
|
||||
await user.login();
|
||||
await expect(page).toHaveURL(/\/environments\/[^/]+/);
|
||||
|
||||
const { environmentId, userId } = await getUserContext(email);
|
||||
const survey = await createSurveySeed(
|
||||
environmentId,
|
||||
userId,
|
||||
`Email Preview Open Text ${timestamp}`,
|
||||
TSurveyElementTypeEnum.OpenText
|
||||
);
|
||||
|
||||
await page.goto(`/environments/${environmentId}/surveys/${survey.id}/summary`);
|
||||
await page.getByRole("button", { name: "Share survey" }).click();
|
||||
await page.getByRole("button", { name: "Email embed" }).click();
|
||||
|
||||
const previewFrame = page.frameLocator('[data-testid="survey-email-preview-frame"]');
|
||||
const openTextLink = previewFrame.getByRole("link", { name: EMBED_SURVEY_PREVIEW_OPEN_TEXT_PLACEHOLDER });
|
||||
const openTextInputShell = openTextLink.locator("xpath=..");
|
||||
|
||||
await expect(previewFrame.getByText(EMBED_SURVEY_PREVIEW_HEADLINE)).toBeVisible();
|
||||
await expect(openTextLink).toBeVisible();
|
||||
await expect(openTextInputShell).toHaveCSS("background-color", "rgb(243, 244, 246)");
|
||||
await expect(openTextInputShell).toHaveCSS("border-top-left-radius", "8px");
|
||||
await expect(openTextInputShell).toHaveCSS("padding-top", "8px");
|
||||
await expect(openTextInputShell).toHaveCSS("padding-left", "8px");
|
||||
await expect(openTextLink).toHaveAttribute("href", /preview=true/);
|
||||
await expect(openTextLink).not.toHaveAttribute("href", /skipPrefilled=true/);
|
||||
await expect(openTextLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ metadata:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and (not .Values.autoscaling.enabled) (not (kindIs "invalid" .Values.deployment.replicas)) }}
|
||||
{{- if .Values.deployment.replicas }}
|
||||
replicas: {{ .Values.deployment.replicas }}
|
||||
{{- end }}
|
||||
selector:
|
||||
|
||||
@@ -83,7 +83,7 @@ deployment:
|
||||
# Additional pod annotations
|
||||
additionalPodAnnotations: {}
|
||||
|
||||
# Number of replicas when autoscaling is disabled
|
||||
# Number of replicas
|
||||
replicas: 1
|
||||
|
||||
# Image pull secrets for private container registries
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
"self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
|
||||
"self-hosting/advanced/enterprise-features/team-access",
|
||||
"self-hosting/advanced/enterprise-features/contact-management-segments",
|
||||
"self-hosting/advanced/enterprise-features/multi-language-surveys",
|
||||
"self-hosting/advanced/enterprise-features/oidc-sso",
|
||||
"self-hosting/advanced/enterprise-features/saml-sso",
|
||||
"self-hosting/advanced/enterprise-features/audit-logging"
|
||||
|
||||
@@ -5,6 +5,8 @@ description: Enable comprehensive audit logs for your Formbricks instance.
|
||||
icon: file-shield
|
||||
---
|
||||
|
||||
import Hint from "@theme/Hint";
|
||||
|
||||
Audit logs record **who** did **what**, **when**, **from where**, and **with what outcome** across your Formbricks instance.
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export function EmbedSurveyPreviewEmail({
|
||||
...legalProps
|
||||
}: EmbedSurveyPreviewEmailProps): React.JSX.Element {
|
||||
return (
|
||||
<EmailTemplate forceLightMode logoUrl={logoUrl} t={t} {...legalProps}>
|
||||
<EmailTemplate logoUrl={logoUrl} t={t} {...legalProps}>
|
||||
<Container>
|
||||
<Heading>{t("emails.embed_survey_preview_email_heading")}</Heading>
|
||||
<Text className="text-sm">{t("emails.embed_survey_preview_email_text")}</Text>
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
import { Container } from "@react-email/components";
|
||||
import type { CSSProperties } from "react";
|
||||
import { cn } from "../../src/lib/cn";
|
||||
|
||||
interface ElementHeaderProps {
|
||||
readonly headline: string;
|
||||
readonly subheader?: string;
|
||||
readonly className?: string;
|
||||
readonly style?: CSSProperties;
|
||||
readonly subheaderStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
export function ElementHeader({
|
||||
headline,
|
||||
subheader,
|
||||
className,
|
||||
style,
|
||||
subheaderStyle,
|
||||
}: ElementHeaderProps): React.JSX.Element {
|
||||
export function ElementHeader({ headline, subheader, className }: ElementHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}
|
||||
style={style}>
|
||||
<Container className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
|
||||
<div dangerouslySetInnerHTML={{ __html: headline }} />
|
||||
</Container>
|
||||
{subheader && (
|
||||
<Container
|
||||
className="text-question-color m-0 mt-2 block p-0 text-sm font-normal leading-6"
|
||||
style={{ ...style, ...subheaderStyle }}>
|
||||
<Container className="text-question-color m-0 mt-2 block p-0 text-sm font-normal leading-6">
|
||||
<div dangerouslySetInnerHTML={{ __html: subheader }} />
|
||||
</Container>
|
||||
)}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { Body, Container, Head, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
|
||||
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
|
||||
import { TEmailTemplateLegalProps } from "../types/email";
|
||||
import { TFunction } from "../types/translations";
|
||||
|
||||
const fbLogoUrl = "https://app.formbricks.com/logo-transparent.png";
|
||||
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
|
||||
const FORCE_LIGHT_COLOR_SCHEME = "only light";
|
||||
|
||||
interface EmailTemplateProps extends TEmailTemplateLegalProps {
|
||||
readonly children: React.ReactNode;
|
||||
readonly forceLightMode?: boolean;
|
||||
readonly logoUrl?: string;
|
||||
readonly t: TFunction;
|
||||
}
|
||||
|
||||
export function EmailTemplate({
|
||||
children,
|
||||
forceLightMode = false,
|
||||
logoUrl,
|
||||
t,
|
||||
privacyUrl,
|
||||
@@ -26,25 +23,10 @@ export function EmailTemplate({
|
||||
|
||||
return (
|
||||
<Html>
|
||||
{forceLightMode && (
|
||||
<Head>
|
||||
<meta name="color-scheme" content="only light" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
<style>
|
||||
{`:root {
|
||||
color-scheme: only light;
|
||||
supported-color-schemes: light;
|
||||
}`}
|
||||
</style>
|
||||
</Head>
|
||||
)}
|
||||
<Tailwind>
|
||||
<Body
|
||||
className="m-0 h-full w-full justify-center bg-slate-50 p-6 text-center text-sm text-slate-800"
|
||||
style={{
|
||||
...(forceLightMode
|
||||
? { backgroundColor: "#f8fafc", color: "#1e293b", colorScheme: FORCE_LIGHT_COLOR_SCHEME }
|
||||
: {}),
|
||||
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
|
||||
}}>
|
||||
<Section>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { embedSurveyPreviewEmailHtml } from "./fixtures/embed-survey-preview-email-html";
|
||||
|
||||
export const exampleData = {
|
||||
verificationEmail: {
|
||||
@@ -42,7 +41,7 @@ export const exampleData = {
|
||||
},
|
||||
|
||||
embedSurveyPreviewEmail: {
|
||||
html: embedSurveyPreviewEmailHtml,
|
||||
html: '<div style="padding: 20px; background-color: #f3f4f6; border-radius: 8px;"><h3 style="margin-top: 0;">Example Survey Embed</h3><p>This is a preview of how your survey will look when embedded in an email.</p></div>',
|
||||
environmentId: "clxyz123456789",
|
||||
},
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
export const embedSurveyPreviewEmailHtml = `
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
bgcolor="#4a865f"
|
||||
style="background-color:#4a865f !important;border-color:rgb(74,134,95);border-radius:8px;margin-right:0rem;margin-left:0rem;margin-bottom:0.5rem;margin-top:0.5rem;border-style:solid;border-width:1px;padding:2rem;color:#1f2937 !important;background:#4a865f !important;color-scheme:only light;border:1px solid #4a865f !important;font-family:Inter, Helvetica, Arial, sans-serif">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="max-width:37.5em;color:#1f2937 !important;margin:0rem;display:block;font-size:16px;line-height:1.5rem;font-weight:600;margin-right:2rem;color-scheme:only light;font-family:Inter, Helvetica, Arial, sans-serif">
|
||||
<tbody>
|
||||
<tr style="width:100%">
|
||||
<td><div>Which fruits do you like</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="max-width:none;margin-right:0rem;margin-left:0rem">
|
||||
<tbody>
|
||||
<tr style="width:100%">
|
||||
<td>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="margin-top:0.5rem;width:100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="https://app.formbricks.com/s/embed-survey-preview?preview=true&embed-survey-preview-question=embed-survey-preview-choice-apples&skipPrefilled=true"
|
||||
style="color:#1f2937 !important;text-decoration-line:none;background-color:#ffffff !important;border-color:rgb(214,228,220);display:block;border-radius:8px;border-style:solid;border-width:1px;padding-right:16px;padding-left:16px;padding-bottom:16px;padding-top:16px;font-family:Inter,Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.25rem;background:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important;text-decoration:none"
|
||||
target="_blank"
|
||||
><span
|
||||
style="margin-right:0.75rem;display:inline-block;height:1rem;width:1rem;box-sizing:border-box;vertical-align:middle;line-height:1rem;border-radius:0.25rem;background:#ffffff !important;background-color:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important"
|
||||
>\u00A0</span
|
||||
><span style="vertical-align:middle">Apples</span></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="margin-top:0.5rem;width:100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="https://app.formbricks.com/s/embed-survey-preview?preview=true&embed-survey-preview-question=embed-survey-preview-choice-bananas&skipPrefilled=true"
|
||||
style="color:#1f2937 !important;text-decoration-line:none;background-color:#ffffff !important;border-color:rgb(214,228,220);display:block;border-radius:8px;border-style:solid;border-width:1px;padding-right:16px;padding-left:16px;padding-bottom:16px;padding-top:16px;font-family:Inter,Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.25rem;background:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important;text-decoration:none"
|
||||
target="_blank"
|
||||
><span
|
||||
style="margin-right:0.75rem;display:inline-block;height:1rem;width:1rem;box-sizing:border-box;vertical-align:middle;line-height:1rem;border-radius:0.25rem;background:#ffffff !important;background-color:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important"
|
||||
>\u00A0</span
|
||||
><span style="vertical-align:middle">Bananas</span></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="margin-top:0.5rem;width:100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="https://app.formbricks.com/s/embed-survey-preview?preview=true&embed-survey-preview-question=embed-survey-preview-choice-pineapples&skipPrefilled=true"
|
||||
style="color:#1f2937 !important;text-decoration-line:none;background-color:#ffffff !important;border-color:rgb(214,228,220);display:block;border-radius:8px;border-style:solid;border-width:1px;padding-right:16px;padding-left:16px;padding-bottom:16px;padding-top:16px;font-family:Inter,Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.25rem;background:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important;text-decoration:none"
|
||||
target="_blank"
|
||||
><span
|
||||
style="margin-right:0.75rem;display:inline-block;height:1rem;width:1rem;box-sizing:border-box;vertical-align:middle;line-height:1rem;border-radius:0.25rem;background:#ffffff !important;background-color:#ffffff !important;color-scheme:only light;border:1px solid #d6e4dc !important"
|
||||
>\u00A0</span
|
||||
><span style="vertical-align:middle"
|
||||
>Pineapples</span
|
||||
></a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="max-width:37.5em;margin-right:auto;margin-left:auto;margin-top:2rem;text-align:center">
|
||||
<tbody>
|
||||
<tr style="width:100%">
|
||||
<td>
|
||||
<a
|
||||
href="https://formbricks.com?utm_source=email_branding"
|
||||
style="color:#4c545f !important;text-decoration-line:none;font-size:0.75rem;line-height:1.3333333333333333;color-scheme:only light;font-family:Inter, Helvetica, Arial, sans-serif"
|
||||
target="_blank"
|
||||
>Powered by Formbricks</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`.trim();
|
||||
@@ -5,8 +5,6 @@ type TranslationKey = string;
|
||||
type TranslationValue = string;
|
||||
|
||||
const translations: Record<TranslationKey, TranslationValue> = {
|
||||
"common.continue": "Continue",
|
||||
"common.powered_by_formbricks": "Powered by Formbricks",
|
||||
"emails.accept": "Accept",
|
||||
"emails.click_or_drag_to_upload_files": "Click or drag to upload files.",
|
||||
"emails.email_customization_preview_email_heading": "Hey {userName}",
|
||||
|
||||
@@ -243,9 +243,9 @@ export const setup = async (
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
const surveyIds = filteredSurveys.map((s) => s.id);
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
logger.debug(
|
||||
`${surveyIds.length.toString()} surveys could be shown to current user on trigger: ${surveyIds.join(", ")}`
|
||||
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
|
||||
);
|
||||
} catch {
|
||||
logger.debug("Error during sync. Please try again.");
|
||||
@@ -303,9 +303,9 @@ export const setup = async (
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
const surveyIds = filteredSurveys.map((s) => s.id);
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
logger.debug(
|
||||
`${surveyIds.length.toString()} surveys could be shown to current user on trigger: ${surveyIds.join(", ")}`
|
||||
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
|
||||
);
|
||||
} catch (e) {
|
||||
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user