mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-10 02:24:17 -05:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5fbc6317d | |||
| b951fbcbc8 | |||
| 7e2c439325 | |||
| a2177eec96 | |||
| 255c97854f | |||
| d103499496 | |||
| b863238f15 | |||
| 28280899ea | |||
| bc63870289 | |||
| 9a04e95d15 | |||
| 9d9f38515d |
@@ -155,3 +155,31 @@ 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 }}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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,6 +2,7 @@ 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";
|
||||
@@ -41,6 +42,16 @@ 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
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@ 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";
|
||||
@@ -242,7 +243,7 @@ export const ProjectSettings = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={previewSurvey(projectName || t("common.my_product"), t)}
|
||||
survey={toJsEnvironmentStateSurvey(previewSurvey(projectName || t("common.my_product"), t))}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
|
||||
+13
@@ -7,6 +7,7 @@ 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 { getUserProjects } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -51,6 +52,18 @@ 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,6 +10,7 @@ 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";
|
||||
@@ -80,6 +81,19 @@ 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,6 +2,8 @@ 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";
|
||||
@@ -25,6 +27,14 @@ 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}
|
||||
|
||||
+16
-2
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -146,6 +147,7 @@ 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");
|
||||
@@ -153,7 +155,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -161,7 +163,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
projectId,
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -178,6 +180,18 @@ 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",
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -18,7 +19,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const styling = getStyling(project, survey);
|
||||
const styling = getStyling(project, toJsEnvironmentStateSurvey(survey));
|
||||
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
|
||||
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
||||
|
||||
|
||||
@@ -42,18 +42,25 @@ 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,
|
||||
});
|
||||
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 }
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -43,14 +43,22 @@ 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,
|
||||
});
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
workspace_id: projectId,
|
||||
environment_id: parsedInput.environmentId,
|
||||
},
|
||||
{ organizationId, workspaceId: projectId }
|
||||
);
|
||||
|
||||
return result;
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
|
||||
const makeParams = (responseCount: number) => ({
|
||||
organizationId: "org-1",
|
||||
workspaceId: "ws-1",
|
||||
surveyId: "survey-1",
|
||||
surveyType: "link",
|
||||
environmentId: "env-1",
|
||||
@@ -23,15 +24,21 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
|
||||
captureSurveyResponsePostHogEvent(makeParams(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",
|
||||
});
|
||||
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" }
|
||||
);
|
||||
});
|
||||
|
||||
test("fires on every 100th response", async () => {
|
||||
@@ -44,10 +51,20 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
test("does NOT fire for 2nd through 99th responses", async () => {
|
||||
test("fires on every 10th response up to 100", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
for (const count of [2, 5, 10, 50, 99]) {
|
||||
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]) {
|
||||
captureSurveyResponsePostHogEvent(makeParams(count));
|
||||
}
|
||||
|
||||
@@ -75,7 +92,8 @@ describe("captureSurveyResponsePostHogEvent", () => {
|
||||
expect.objectContaining({
|
||||
is_first_response: false,
|
||||
milestone: "200",
|
||||
})
|
||||
}),
|
||||
{ organizationId: "org-1", workspaceId: "ws-1" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { capturePostHogEvent } from "@/lib/posthog";
|
||||
|
||||
interface SurveyResponsePostHogEventParams {
|
||||
organizationId: string;
|
||||
workspaceId: string;
|
||||
surveyId: string;
|
||||
surveyType: string;
|
||||
environmentId: string;
|
||||
@@ -10,24 +11,36 @@ interface SurveyResponsePostHogEventParams {
|
||||
|
||||
/**
|
||||
* Captures a PostHog event for survey responses at milestones:
|
||||
* 1st response, then every 100th (100, 200, 300, ...).
|
||||
* 1st response, every 10th for the first 100 (10, 20, ..., 100),
|
||||
* then every 100th (200, 300, 400, ...).
|
||||
*/
|
||||
export const captureSurveyResponsePostHogEvent = ({
|
||||
organizationId,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
surveyType,
|
||||
environmentId,
|
||||
responseCount,
|
||||
}: SurveyResponsePostHogEventParams): void => {
|
||||
if (responseCount !== 1 && responseCount % 100 !== 0) return;
|
||||
const isFirst = responseCount === 1;
|
||||
const isEvery10thUnder100 = responseCount <= 100 && responseCount % 10 === 0;
|
||||
const isEvery100thAbove100 = responseCount > 100 && responseCount % 100 === 0;
|
||||
|
||||
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),
|
||||
});
|
||||
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 }
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ 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";
|
||||
@@ -307,9 +308,11 @@ 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 } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
@@ -87,10 +87,18 @@ export const GET = async (req: Request) => {
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(session.user.id, "integration_connected", {
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
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 }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
name: true,
|
||||
// name intentionally omitted — internal label not needed by the SDK
|
||||
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: {
|
||||
include: {
|
||||
surveys: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
filters: true,
|
||||
},
|
||||
},
|
||||
recontactDays: true,
|
||||
@@ -147,10 +147,28 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
throw new ResourceNotFoundError("project", null);
|
||||
}
|
||||
|
||||
// Transform surveys using existing utility
|
||||
const transformedSurveys = environmentData.surveys.map((survey) =>
|
||||
transformPrismaSurvey<TJsEnvironmentStateSurvey>(survey)
|
||||
);
|
||||
// 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 };
|
||||
});
|
||||
|
||||
return {
|
||||
environment: {
|
||||
|
||||
+21
-5
@@ -10,6 +10,11 @@ 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: {
|
||||
@@ -22,6 +27,9 @@ vi.mock("@formbricks/database", () => ({
|
||||
environment: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
@@ -163,6 +171,7 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
// Default mocks for successful retrieval
|
||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
|
||||
vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId: "test-org-id" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -329,11 +338,18 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
await getEnvironmentState(environmentId);
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(environmentId, "app_connected", {
|
||||
num_surveys: 1,
|
||||
num_code_actions: 1,
|
||||
num_no_code_actions: 1,
|
||||
});
|
||||
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" }
|
||||
);
|
||||
});
|
||||
|
||||
test("should not capture app_connected event when app setup already completed", async () => {
|
||||
|
||||
@@ -33,11 +33,25 @@ export const getEnvironmentState = async (
|
||||
});
|
||||
|
||||
if (POSTHOG_KEY) {
|
||||
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 workspaceId = environment.project.id;
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: workspaceId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
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 } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
const getEmail = async (token: string) => {
|
||||
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
|
||||
@@ -95,10 +95,18 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "airtable",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
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 }
|
||||
);
|
||||
} 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 } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -101,10 +101,18 @@ export const GET = withV1ApiWrapper({
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "notion",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
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 }
|
||||
);
|
||||
} 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 } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -109,10 +109,18 @@ export const GET = withV1ApiWrapper({
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "slack",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
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 }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"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 } from "@/lib/posthog";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } 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,10 +50,17 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
ctx.auditLoggingCtx.organizationId = newOrganization.id;
|
||||
ctx.auditLoggingCtx.newObject = newOrganization;
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "organization_created", {
|
||||
organization_id: newOrganization.id,
|
||||
is_first_org: hasNoOrganizations,
|
||||
});
|
||||
groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name });
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"organization_created",
|
||||
{
|
||||
organization_id: newOrganization.id,
|
||||
is_first_org: hasNoOrganizations,
|
||||
},
|
||||
{ organizationId: newOrganization.id }
|
||||
);
|
||||
|
||||
return newOrganization;
|
||||
})
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
@@ -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,8 +1,9 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { capturePostHogEvent } from "./capture";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
capture: vi.fn(),
|
||||
groupIdentify: vi.fn(),
|
||||
loggerWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -13,7 +14,7 @@ vi.mock("@formbricks/logger", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./server", () => ({
|
||||
posthogServerClient: { capture: mocks.capture },
|
||||
posthogServerClient: { capture: mocks.capture, groupIdentify: mocks.groupIdentify },
|
||||
}));
|
||||
|
||||
describe("capturePostHogEvent", () => {
|
||||
@@ -32,6 +33,7 @@ describe("capturePostHogEvent", () => {
|
||||
$lib: "posthog-node",
|
||||
source: "server",
|
||||
},
|
||||
groups: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +47,41 @@ 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" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +98,44 @@ 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();
|
||||
@@ -74,11 +149,14 @@ describe("capturePostHogEvent with null client", () => {
|
||||
posthogServerClient: null,
|
||||
}));
|
||||
|
||||
const { capturePostHogEvent: captureWithNullClient } = await import("./capture");
|
||||
const { capturePostHogEvent: captureWithNullClient, groupIdentifyPostHog: identifyWithNullClient } =
|
||||
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,10 +4,24 @@ 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
|
||||
properties?: PostHogEventProperties,
|
||||
groupContext?: PostHogGroupContext
|
||||
): void {
|
||||
if (!posthogServerClient) return;
|
||||
|
||||
@@ -20,8 +34,29 @@ 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,5 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
export { capturePostHogEvent } from "./capture";
|
||||
export { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
|
||||
export type { PostHogGroupContext } from "./capture";
|
||||
export { getPostHogFeatureFlag } from "./get-feature-flag";
|
||||
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
};
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
|
||||
"delete_workspace": "Projekt löschen",
|
||||
"delete_workspace_confirmation": "Sind Sie sicher, dass Sie {projectName} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_workspace_confirmation_name": "Bitte gib {projectName} in das folgende Feld ein, um die endgültige Löschung dieses Projekts zu bestätigen:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName} inkl. aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen.",
|
||||
"delete_workspace_settings_description": "Projekt mit allen Umfragen, Antworten, Personen, Aktionen und Attributen löschen. Das kann nicht rückgängig gemacht werden.",
|
||||
"error_saving_workspace_information": "Fehler beim Speichern der Projektinformationen",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Bitte überprüfe auch deinen Spam-Ordner, falls Du die E-Mail nicht in deinem Posteingang siehst.",
|
||||
"completed": "Diese kostenlose und quelloffene Umfrage wurde geschlossen.",
|
||||
"completed_heading": "Abgeschlossen",
|
||||
"create_your_own": "Erstelle deine eigene",
|
||||
"enter_pin": "Diese Umfrage ist geschützt. Gib die PIN unten ein.",
|
||||
"just_curious": "Einfach neugierig?",
|
||||
"link_invalid": "Diese Umfrage kann nur auf Einladung durchgeführt werden.",
|
||||
"paused": "Diese Umfrage ist vorübergehend pausiert.",
|
||||
"paused_heading": "Pausiert",
|
||||
"please_try_again_with_the_original_link": "Bitte versuche es nochmal mit dem ursprünglichen Link",
|
||||
"preview_survey_questions": "Vorschau der Fragen.",
|
||||
"question_preview": "Vorschau der Frage",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
|
||||
"delete_workspace": "Delete Workspace",
|
||||
"delete_workspace_confirmation": "Are you sure you want to delete {projectName}? This action cannot be undone.",
|
||||
"delete_workspace_confirmation_name": "Please enter {projectName} in the following field to confirm the definitive deletion of this workspace:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Delete {projectName} including all surveys, responses, people, actions and attributes.",
|
||||
"delete_workspace_settings_description": "Delete workspace with all surveys, responses, people, actions and attributes. This cannot be undone.",
|
||||
"error_saving_workspace_information": "Error saving workspace information",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Please also check your spam folder if you do not see the email in your inbox.",
|
||||
"completed": "This survey is closed.",
|
||||
"completed_heading": "Completed",
|
||||
"create_your_own": "Create your own open-source survey",
|
||||
"enter_pin": "This survey is protected. Enter the PIN below.",
|
||||
"just_curious": "Just curious?",
|
||||
"link_invalid": "This survey can only be taken by invitation.",
|
||||
"paused": "This survey is temporarily paused.",
|
||||
"paused_heading": "Paused",
|
||||
"please_try_again_with_the_original_link": "Please try again with the original link",
|
||||
"preview_survey_questions": "Preview survey questions.",
|
||||
"question_preview": "Question Preview",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
|
||||
"delete_workspace": "Eliminar proyecto",
|
||||
"delete_workspace_confirmation": "¿Estás seguro de que quieres eliminar {projectName}? Esta acción no se puede deshacer.",
|
||||
"delete_workspace_confirmation_name": "Por favor, introduce {projectName} en el siguiente campo para confirmar la eliminación definitiva de este proyecto:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluyendo todas las encuestas, respuestas, personas, acciones y atributos.",
|
||||
"delete_workspace_settings_description": "Eliminar proyecto con todas las encuestas, respuestas, personas, acciones y atributos. Esto no se puede deshacer.",
|
||||
"error_saving_workspace_information": "Error al guardar la información del proyecto",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Por favor, comprueba también tu carpeta de spam si no ves el correo electrónico en tu bandeja de entrada.",
|
||||
"completed": "Esta encuesta está cerrada.",
|
||||
"completed_heading": "Completado",
|
||||
"create_your_own": "Crea tu propia encuesta de código abierto",
|
||||
"enter_pin": "Esta encuesta está protegida. Introduce el PIN a continuación.",
|
||||
"just_curious": "¿Solo tienes curiosidad?",
|
||||
"link_invalid": "Esta encuesta solo se puede realizar por invitación.",
|
||||
"paused": "Esta encuesta está temporalmente pausada.",
|
||||
"paused_heading": "Pausado",
|
||||
"please_try_again_with_the_original_link": "Por favor, inténtalo de nuevo con el enlace original",
|
||||
"preview_survey_questions": "Vista previa de las preguntas de la encuesta.",
|
||||
"question_preview": "Vista previa de la pregunta",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
|
||||
"delete_workspace": "Supprimer le projet",
|
||||
"delete_workspace_confirmation": "Êtes-vous sûr de vouloir supprimer {projectName} ? Cette action ne peut pas être annulée.",
|
||||
"delete_workspace_confirmation_name": "Veuillez entrer {projectName} dans le champ suivant pour confirmer la suppression définitive de ce projet :",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Supprimer {projectName} y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
|
||||
"delete_workspace_settings_description": "Supprimer le projet avec toutes les enquêtes, réponses, personnes, actions et attributs. Cette opération est irréversible.",
|
||||
"error_saving_workspace_information": "Erreur lors de l'enregistrement des informations du projet",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Veuillez également vérifier votre dossier de spam si vous ne voyez pas l'e-mail dans votre boîte de réception.",
|
||||
"completed": "Cette enquête gratuite et open-source a été fermée.",
|
||||
"completed_heading": "Terminé",
|
||||
"create_your_own": "Créez le vôtre",
|
||||
"enter_pin": "Ce sondage est protégé. Entrez le code PIN ci-dessous.",
|
||||
"just_curious": "Juste curieux ?",
|
||||
"link_invalid": "Cette enquête ne peut être réalisée que sur invitation.",
|
||||
"paused": "Cette enquête gratuite et open-source est temporairement suspendue.",
|
||||
"paused_heading": "En pause",
|
||||
"please_try_again_with_the_original_link": "Veuillez réessayer avec le lien d'origine.",
|
||||
"preview_survey_questions": "Aperçu des questions de l'enquête.",
|
||||
"question_preview": "Aperçu de la question",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "A parancsfájlok teljes böngésző-hozzáféréssel kerülnek végrehajtásra. Csak megbízható forrásokból származó parancsfájlokat adjon hozzá.",
|
||||
"delete_workspace": "Munkaterület törlése",
|
||||
"delete_workspace_confirmation": "Biztosan törölni szeretné a(z) {projectName} munkaterületet? Ezt a műveletet nem lehet visszavonni.",
|
||||
"delete_workspace_confirmation_name": "Adja meg a(z) {projectName} munkaterület nevét a következő mezőben a munkaterület végleges törlésének megerősítéséhez:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "A(z) {projectName} munkaterület törlése, beleértve az összes kérdőívet, választ, személyt, műveletet és attribútumot is.",
|
||||
"delete_workspace_settings_description": "A munkaterület törlése az összes kérdőívvel, válasszal, személlyel, művelettel és attribútummal együtt. Ezt nem lehet visszavonni.",
|
||||
"error_saving_workspace_information": "Hiba a munkaterület-információk mentésekor",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Nézze meg a levélszemét mappát is, ha nem találja az e-mailt a beérkező levelek között.",
|
||||
"completed": "Ez a kérdőív le van zárva.",
|
||||
"completed_heading": "Befejezve",
|
||||
"create_your_own": "Saját nyílt forráskódú kérdőív létrehozása",
|
||||
"enter_pin": "Ez a kérdőív védett. Adja meg a PIN-kódot lent.",
|
||||
"just_curious": "Csak kíváncsi?",
|
||||
"link_invalid": "Ez a kérdőív csak meghívás útján tölthető ki.",
|
||||
"paused": "Ez a kérdőív átmenetileg szüneteltetve van.",
|
||||
"paused_heading": "Szüneteltetve",
|
||||
"please_try_again_with_the_original_link": "Próbálja meg újra az eredeti hivatkozással",
|
||||
"preview_survey_questions": "Kérdőív kérdéseinek előnézete.",
|
||||
"question_preview": "Kérdés előnézete",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
|
||||
"delete_workspace": "ワークスペースを削除",
|
||||
"delete_workspace_confirmation": "{projectName}を削除してもよろしいですか?このアクションは元に戻せません。",
|
||||
"delete_workspace_confirmation_name": "このワークスペースの完全な削除を確認するには、以下のフィールドに {projectName} と入力してください:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
|
||||
"delete_workspace_settings_description": "すべてのフォーム、回答、人物、アクション、属性を含むワークスペースを削除します。この操作は元に戻せません。",
|
||||
"error_saving_workspace_information": "ワークスペース情報の保存中にエラーが発生しました",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "受信トレイにメールがない場合は、迷惑メールフォルダも確認してください。",
|
||||
"completed": "このフォームはクローズしました。",
|
||||
"completed_heading": "完了",
|
||||
"create_your_own": "独自のオープンソースフォームを作成",
|
||||
"enter_pin": "このアンケートは保護されています。以下にPINを入力してください。",
|
||||
"just_curious": "ただ興味があるだけですか?",
|
||||
"link_invalid": "このフォームは招待によってのみ回答できます。",
|
||||
"paused": "このフォームは一時的に一時停止されています。",
|
||||
"paused_heading": "一時停止",
|
||||
"please_try_again_with_the_original_link": "元のリンクでもう一度お試しください",
|
||||
"preview_survey_questions": "フォームの質問をプレビュー。",
|
||||
"question_preview": "質問プレビュー",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
|
||||
"delete_workspace": "Project verwijderen",
|
||||
"delete_workspace_confirmation": "Weet u zeker dat u {projectName} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"delete_workspace_confirmation_name": "Voer {projectName} in het volgende veld in om de definitieve verwijdering van dit project te bevestigen:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Verwijder {projectName} incl. alle enquêtes, reacties, mensen, acties en attributen.",
|
||||
"delete_workspace_settings_description": "Verwijder project met alle enquêtes, reacties, mensen, acties en attributen. Dit kan niet ongedaan worden gemaakt.",
|
||||
"error_saving_workspace_information": "Fout bij opslaan van projectinformatie",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Controleer ook uw spammap als u de e-mail niet in uw inbox ziet.",
|
||||
"completed": "Deze enquête is gesloten.",
|
||||
"completed_heading": "Voltooid",
|
||||
"create_your_own": "Creëer uw eigen open source-enquête",
|
||||
"enter_pin": "Deze enquête is beveiligd. Voer hieronder de pincode in.",
|
||||
"just_curious": "Gewoon nieuwsgierig?",
|
||||
"link_invalid": "Aan deze enquête kan alleen op uitnodiging worden deelgenomen.",
|
||||
"paused": "Deze enquête is tijdelijk onderbroken.",
|
||||
"paused_heading": "Gepauzeerd",
|
||||
"please_try_again_with_the_original_link": "Probeer het opnieuw met de originele link",
|
||||
"preview_survey_questions": "Bekijk enquêtevragen.",
|
||||
"question_preview": "Vraagvoorbeeld",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
|
||||
"delete_workspace": "Excluir projeto",
|
||||
"delete_workspace_confirmation": "Tem certeza de que deseja excluir {projectName}? Essa ação não pode ser desfeita.",
|
||||
"delete_workspace_confirmation_name": "Por favor, insira {projectName} no campo abaixo para confirmar a exclusão definitiva deste projeto:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Excluir {projectName} incluindo todas as pesquisas, respostas, pessoas, ações e atributos.",
|
||||
"delete_workspace_settings_description": "Excluir projeto com todas as pesquisas, respostas, pessoas, ações e atributos. Isso não pode ser desfeito.",
|
||||
"error_saving_workspace_information": "Erro ao salvar informações do projeto",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Por favor, dá uma olhada na sua pasta de spam se você não encontrar o e-mail na sua caixa de entrada.",
|
||||
"completed": "Essa pesquisa gratuita e de código aberto foi encerrada.",
|
||||
"completed_heading": "Concluído",
|
||||
"create_your_own": "Crie o seu próprio",
|
||||
"enter_pin": "Esta pesquisa está protegida. Digite o PIN abaixo.",
|
||||
"just_curious": "Só curioso?",
|
||||
"link_invalid": "Essa pesquisa só pode ser respondida por convite.",
|
||||
"paused": "Essa pesquisa gratuita e de código aberto está temporariamente pausada.",
|
||||
"paused_heading": "Pausado",
|
||||
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
|
||||
"preview_survey_questions": "Visualizar perguntas da pesquisa.",
|
||||
"question_preview": "Prévia da Pergunta",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
|
||||
"delete_workspace": "Eliminar projeto",
|
||||
"delete_workspace_confirmation": "Tem a certeza de que pretende eliminar {projectName}? Esta ação não pode ser desfeita.",
|
||||
"delete_workspace_confirmation_name": "Por favor, insira {projectName} no campo seguinte para confirmar a eliminação definitiva deste projeto:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluindo todos os inquéritos, respostas, pessoas, ações e atributos.",
|
||||
"delete_workspace_settings_description": "Eliminar projeto com todos os inquéritos, respostas, pessoas, ações e atributos. Isto não pode ser desfeito.",
|
||||
"error_saving_workspace_information": "Erro ao guardar informações do projeto",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Por favor, verifique também a sua pasta de spam se não vir o email na sua caixa de entrada.",
|
||||
"completed": "Este inquérito está encerrado.",
|
||||
"completed_heading": "Concluído",
|
||||
"create_your_own": "Crie o seu próprio inquérito de código aberto",
|
||||
"enter_pin": "Este inquérito está protegido. Introduza o PIN abaixo.",
|
||||
"just_curious": "Só por curiosidade?",
|
||||
"link_invalid": "Este inquérito só pode ser respondido por convite.",
|
||||
"paused": "Este inquérito está temporariamente suspenso.",
|
||||
"paused_heading": "Em pausa",
|
||||
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
|
||||
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
|
||||
"question_preview": "Pré-visualização da Pergunta",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
|
||||
"delete_workspace": "Șterge proiectul",
|
||||
"delete_workspace_confirmation": "Sigur vrei să ștergi {projectName}? Această acțiune nu poate fi anulată.",
|
||||
"delete_workspace_confirmation_name": "Vă rugăm să introduceți {projectName} în câmpul următor pentru a confirma ștergerea definitivă a acestui proiect:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Șterge {projectName} incl. toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
|
||||
"delete_workspace_settings_description": "Șterge proiectul cu toate sondajele, răspunsurile, persoanele, acțiunile și atributele. Aceasta nu poate fi anulată.",
|
||||
"error_saving_workspace_information": "Eroare la salvarea informațiilor despre proiect",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Vă rugăm să verificați și folderul de spam dacă nu vedeți emailul în inbox.",
|
||||
"completed": "Acest chestionar este închis.",
|
||||
"completed_heading": "Completat",
|
||||
"create_your_own": "Creează-ți propriul chestionar open-source",
|
||||
"enter_pin": "Acest sondaj este protejat. Introduceți PIN-ul mai jos.",
|
||||
"just_curious": "Doar curios?",
|
||||
"link_invalid": "Acest sondaj poate fi completat doar pe bază de invitație.",
|
||||
"paused": "Acest sondaj este temporar întrerupt.",
|
||||
"paused_heading": "Pauză",
|
||||
"please_try_again_with_the_original_link": "Vă rugăm să încercați din nou cu linkul original",
|
||||
"preview_survey_questions": "Previzualizare întrebări chestionar",
|
||||
"question_preview": "Previzualizare Întrebare",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
|
||||
"delete_workspace": "Удалить рабочий проект",
|
||||
"delete_workspace_confirmation": "Вы уверены, что хотите удалить {projectName}? Это действие необратимо.",
|
||||
"delete_workspace_confirmation_name": "Пожалуйста, введите {projectName} в поле ниже для подтверждения окончательного удаления этого рабочего проекта:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Удалить {projectName} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
|
||||
"delete_workspace_settings_description": "Удалить рабочий проект со всеми опросами, ответами, пользователями, действиями и атрибутами. Это действие необратимо.",
|
||||
"error_saving_workspace_information": "Ошибка при сохранении информации о рабочем проекте",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Если вы не видите письмо во входящих, проверьте папку со спамом.",
|
||||
"completed": "Этот опрос закрыт.",
|
||||
"completed_heading": "Завершено",
|
||||
"create_your_own": "Создайте свой собственный опрос с открытым исходным кодом",
|
||||
"enter_pin": "Этот опрос защищён. Введите PIN ниже.",
|
||||
"just_curious": "Просто интересно?",
|
||||
"link_invalid": "Пройти этот опрос можно только по приглашению.",
|
||||
"paused": "Этот опрос временно приостановлен.",
|
||||
"paused_heading": "Приостановлено",
|
||||
"please_try_again_with_the_original_link": "Пожалуйста, попробуйте снова, используя оригинальную ссылку",
|
||||
"preview_survey_questions": "Просмотреть вопросы опроса.",
|
||||
"question_preview": "Предпросмотр вопроса",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
|
||||
"delete_workspace": "Ta bort arbetsyta",
|
||||
"delete_workspace_confirmation": "Är du säker på att du vill ta bort {projectName}? Denna åtgärd kan inte ångras.",
|
||||
"delete_workspace_confirmation_name": "Vänligen ange {projectName} i följande fält för att bekräfta den definitiva borttagningen av denna arbetsyta:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Ta bort {projectName} inkl. alla enkäter, svar, personer, åtgärder och attribut.",
|
||||
"delete_workspace_settings_description": "Ta bort arbetsyta med alla enkäter, svar, personer, åtgärder och attribut. Detta kan inte ångras.",
|
||||
"error_saving_workspace_information": "Fel vid sparande av arbetsytans information",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Vänligen kontrollera även din skräppost om du inte ser e-postmeddelandet i din inkorg.",
|
||||
"completed": "Denna enkät är stängd.",
|
||||
"completed_heading": "Slutförd",
|
||||
"create_your_own": "Skapa din egen öppenkällkodsenkät",
|
||||
"enter_pin": "Denna undersökning är skyddad. Ange PIN-koden nedan.",
|
||||
"just_curious": "Bara nyfiken?",
|
||||
"link_invalid": "Denna enkät kan endast tas via inbjudan.",
|
||||
"paused": "Denna enkät är tillfälligt pausad.",
|
||||
"paused_heading": "Pausad",
|
||||
"please_try_again_with_the_original_link": "Vänligen försök igen med den ursprungliga länken",
|
||||
"preview_survey_questions": "Förhandsgranska enkätfrågor.",
|
||||
"question_preview": "Frågeförhandsgranskning",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "Betikler tam tarayıcı erişimiyle çalışır. Yalnızca güvenilir kaynaklardan betik ekleyin.",
|
||||
"delete_workspace": "Çalışma Alanını Sil",
|
||||
"delete_workspace_confirmation": "{projectName} öğesini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"delete_workspace_confirmation_name": "Bu çalışma alanının kesin olarak silinmesini onaylamak için lütfen aşağıdaki alana {projectName} yazın:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Tüm survey, yanıt, kişi, eylem ve nitelikleri dahil {projectName} öğesini silin.",
|
||||
"delete_workspace_settings_description": "Tüm survey, yanıt, kişi, eylem ve nitelikleriyle birlikte çalışma alanını silin. Bu işlem geri alınamaz.",
|
||||
"error_saving_workspace_information": "Çalışma alanı bilgileri kaydedilirken hata oluştu",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Email'i gelen kutunuzda görmüyorsanız lütfen spam klasörünüzü de kontrol edin.",
|
||||
"completed": "Bu survey kapatılmıştır.",
|
||||
"completed_heading": "Tamamlandı",
|
||||
"create_your_own": "Kendi açık kaynak survey'inizi oluşturun",
|
||||
"enter_pin": "Bu survey korumalıdır. Aşağıya PIN girin.",
|
||||
"just_curious": "Sadece merak mı ediyorsunuz?",
|
||||
"link_invalid": "Bu survey yalnızca davet ile katılıma açıktır.",
|
||||
"paused": "Bu survey geçici olarak duraklatılmıştır.",
|
||||
"paused_heading": "Duraklatıldı",
|
||||
"please_try_again_with_the_original_link": "Lütfen orijinal bağlantıyla tekrar deneyin",
|
||||
"preview_survey_questions": "Survey sorularını önizleyin.",
|
||||
"question_preview": "Soru Önizlemesi",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
|
||||
"delete_workspace": "删除工作区",
|
||||
"delete_workspace_confirmation": "您确定要删除 {projectName} 吗?此操作无法撤销。",
|
||||
"delete_workspace_confirmation_name": "请在下列字段中输入 {projectName} 以确认永久删除此工作区:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "删除 {projectName},包括所有调查、回应、人员、动作和属性。",
|
||||
"delete_workspace_settings_description": "删除工作区及其所有调查、回应、人员、动作和属性。此操作无法撤销。",
|
||||
"error_saving_workspace_information": "保存工作区信息时出错",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "请 也 检查 您 的 垃圾邮件 文件夹 如果 您 没有 在 收件箱 中 看到 邮件。",
|
||||
"completed": "此 调查 关闭",
|
||||
"completed_heading": "完成",
|
||||
"create_your_own": "创建 你 的 开源 调查",
|
||||
"enter_pin": "本调查已受保护。请在下方输入PIN码。",
|
||||
"just_curious": "只是好奇 ?",
|
||||
"link_invalid": "此调查只能通过邀请参加。",
|
||||
"paused": "此调查暂时暂停。",
|
||||
"paused_heading": "暂停",
|
||||
"please_try_again_with_the_original_link": "请 尝试 使用 原始 链接",
|
||||
"preview_survey_questions": "预览 问卷调查 问题。",
|
||||
"question_preview": "问题 预览",
|
||||
@@ -2447,9 +2450,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": {
|
||||
|
||||
@@ -2212,6 +2212,7 @@
|
||||
"custom_scripts_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
|
||||
"delete_workspace": "刪除工作區",
|
||||
"delete_workspace_confirmation": "您確定要刪除 {projectName} 嗎?此操作無法復原。",
|
||||
"delete_workspace_confirmation_name": "請在下列欄位中輸入 {projectName} 以確認永久刪除此工作區:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "刪除 {projectName}(包含所有問卷、回應、人員、操作和屬性)。",
|
||||
"delete_workspace_settings_description": "刪除工作區及其所有問卷、回應、人員、操作和屬性。此操作無法復原。",
|
||||
"error_saving_workspace_information": "儲存工作區資訊時發生錯誤",
|
||||
@@ -2429,11 +2430,13 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "如果您的收件匣中沒有看到電子郵件,也請檢查您的垃圾郵件資料夾。",
|
||||
"completed": "此免費且開源的問卷已關閉。",
|
||||
"completed_heading": "已完成",
|
||||
"create_your_own": "建立您自己的",
|
||||
"enter_pin": "此問卷已受保護。請在下方輸入 PIN 碼。",
|
||||
"just_curious": "只是好奇?",
|
||||
"link_invalid": "此問卷只能透過邀請填寫。",
|
||||
"paused": "此免費且開源的問卷已暫時暫停。",
|
||||
"paused_heading": "已暫停",
|
||||
"please_try_again_with_the_original_link": "請使用原始連結再試一次",
|
||||
"preview_survey_questions": "預覽問卷問題。",
|
||||
"question_preview": "問題預覽",
|
||||
@@ -2447,9 +2450,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,6 +4,7 @@ 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";
|
||||
@@ -80,6 +81,8 @@ 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);
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { verifyInviteToken } from "@/lib/jwt";
|
||||
import { createMembership } from "@/lib/membership/service";
|
||||
import { createOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { createOrganization, getOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } 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,6 +116,15 @@ 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(
|
||||
{
|
||||
@@ -166,10 +175,17 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
|
||||
});
|
||||
}
|
||||
|
||||
capturePostHogEvent(user.id, "organization_created", {
|
||||
organization_id: organization.id,
|
||||
is_first_org: true,
|
||||
});
|
||||
groupIdentifyPostHog("organization", organization.id, { name: organization.name });
|
||||
|
||||
capturePostHogEvent(
|
||||
user.id,
|
||||
"organization_created",
|
||||
{
|
||||
organization_id: organization.id,
|
||||
is_first_org: true,
|
||||
},
|
||||
{ organizationId: organization.id }
|
||||
);
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
@@ -236,12 +252,19 @@ 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,
|
||||
});
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
|
||||
@@ -220,9 +220,14 @@ export const startHobbyAction = authenticatedActionClient
|
||||
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
|
||||
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "stayed_on_hobby_plan", {
|
||||
organization_id: parsedInput.organizationId,
|
||||
});
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"stayed_on_hobby_plan",
|
||||
{
|
||||
organization_id: parsedInput.organizationId,
|
||||
},
|
||||
{ organizationId: parsedInput.organizationId }
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
@@ -257,15 +262,25 @@ 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,
|
||||
});
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"free_trial_started",
|
||||
{
|
||||
plan: "pro",
|
||||
organization_id: parsedInput.organizationId,
|
||||
trial_duration_days: 14,
|
||||
},
|
||||
{ organizationId: parsedInput.organizationId }
|
||||
);
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "reverse_trial_started", {
|
||||
organization_id: parsedInput.organizationId,
|
||||
});
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"reverse_trial_started",
|
||||
{
|
||||
organization_id: parsedInput.organizationId,
|
||||
},
|
||||
{ organizationId: parsedInput.organizationId }
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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";
|
||||
@@ -54,6 +55,17 @@ 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,9 +1,11 @@
|
||||
"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 {
|
||||
@@ -113,6 +115,10 @@ 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,
|
||||
@@ -124,6 +130,20 @@ 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,6 +4,7 @@ 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";
|
||||
@@ -62,6 +63,18 @@ 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,6 +5,7 @@ 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";
|
||||
@@ -59,6 +60,7 @@ 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 ?? "",
|
||||
@@ -71,7 +73,7 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -92,6 +94,18 @@ 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,6 +3,7 @@ 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";
|
||||
@@ -52,7 +53,13 @@ export const evaluateResponseQuotas = async (input: QuotaEvaluationInput): Promi
|
||||
return { shouldEndSurvey: false };
|
||||
}
|
||||
const isDefaultLanguage = survey.languages.find((lang) => lang.default)?.language.code === language;
|
||||
const result = evaluateQuotas(survey, data, variables, quotas, isDefaultLanguage ? "default" : language);
|
||||
const result = evaluateQuotas(
|
||||
toJsEnvironmentStateSurvey(survey),
|
||||
data,
|
||||
variables,
|
||||
quotas,
|
||||
isDefaultLanguage ? "default" : language
|
||||
);
|
||||
|
||||
const quotaFull = await handleQuotas(surveyId, responseId, result, responseFinished, prismaClient);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -64,9 +65,39 @@ export const updateProjectBrandingAction = authenticatedActionClient.inputSchema
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = parsedInput.projectId;
|
||||
ctx.auditLoggingCtx.oldObject = await getProject(parsedInput.projectId);
|
||||
const oldProject = await getProject(parsedInput.projectId);
|
||||
ctx.auditLoggingCtx.oldObject = oldProject;
|
||||
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;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -31,6 +32,7 @@ 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,
|
||||
@@ -42,7 +44,7 @@ export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreate
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -53,6 +55,19 @@ 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,6 +7,7 @@ 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";
|
||||
@@ -47,10 +48,34 @@ export const createOrganizationAction = authenticatedActionClient
|
||||
});
|
||||
}
|
||||
|
||||
await createProject(newOrganization.id, {
|
||||
const newProject = 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,10 +328,15 @@ 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,
|
||||
});
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"team_member_invited",
|
||||
{
|
||||
organization_id: parsedInput.organizationId,
|
||||
invitee_role: parsedInput.role,
|
||||
},
|
||||
{ organizationId: parsedInput.organizationId }
|
||||
);
|
||||
|
||||
return inviteId;
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -72,6 +73,35 @@ 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;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { deleteProjectAction } from "./actions";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleteProjectWithConfirmation: vi.fn(),
|
||||
getProjectIdForLogging: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: mocks.loggerError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client", () => ({
|
||||
authenticatedActionClient: {
|
||||
inputSchema: vi.fn(() => ({
|
||||
action: vi.fn((fn) => fn),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("./lib/delete-project", () => ({
|
||||
deleteProjectWithConfirmation: mocks.deleteProjectWithConfirmation,
|
||||
getProjectIdForLogging: mocks.getProjectIdForLogging,
|
||||
}));
|
||||
|
||||
const baseProject = {
|
||||
id: "cmproject000000000000000000",
|
||||
name: "Acme Workspace",
|
||||
organizationId: "cmorg00000000000000000000",
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
user: {
|
||||
id: "cmuser00000000000000000000",
|
||||
},
|
||||
auditLoggingCtx: {},
|
||||
};
|
||||
|
||||
describe("deleteProjectAction", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getProjectIdForLogging.mockReturnValue(baseProject.id);
|
||||
mocks.deleteProjectWithConfirmation.mockResolvedValue(baseProject);
|
||||
});
|
||||
|
||||
test("delegates workspace deletion to the covered lib", async () => {
|
||||
const parsedInput = {
|
||||
projectId: baseProject.id,
|
||||
confirmationName: "acme workspace",
|
||||
};
|
||||
|
||||
const result = await deleteProjectAction({
|
||||
ctx,
|
||||
parsedInput,
|
||||
} as any);
|
||||
|
||||
expect(mocks.getProjectIdForLogging).toHaveBeenCalledWith(parsedInput);
|
||||
expect(mocks.deleteProjectWithConfirmation).toHaveBeenCalledWith({
|
||||
input: parsedInput,
|
||||
userId: ctx.user.id,
|
||||
auditLoggingCtx: ctx.auditLoggingCtx,
|
||||
});
|
||||
expect(result).toEqual(baseProject);
|
||||
});
|
||||
|
||||
test("logs and rethrows deletion failures", async () => {
|
||||
const error = new Error("delete failed");
|
||||
mocks.deleteProjectWithConfirmation.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
deleteProjectAction({
|
||||
ctx,
|
||||
parsedInput: {
|
||||
projectId: baseProject.id,
|
||||
confirmationName: baseProject.name,
|
||||
},
|
||||
} as any)
|
||||
).rejects.toThrow(error);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ error, userId: ctx.user.id, projectId: baseProject.id },
|
||||
"Workspace deletion failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("does not error-log expected deletion failures", async () => {
|
||||
const error = new InvalidInputError("Workspace name confirmation does not match");
|
||||
mocks.deleteProjectWithConfirmation.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
deleteProjectAction({
|
||||
ctx,
|
||||
parsedInput: {
|
||||
projectId: baseProject.id,
|
||||
confirmationName: "Other Workspace",
|
||||
},
|
||||
} as any)
|
||||
).rejects.toThrow(error);
|
||||
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,44 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { getProject, getUserProjects } from "@/lib/project/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { isExpectedError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { deleteProject } from "@/modules/projects/settings/lib/project";
|
||||
import { deleteProjectWithConfirmation, getProjectIdForLogging } from "./lib/delete-project";
|
||||
|
||||
const ZProjectDeleteAction = z.object({
|
||||
projectId: ZId,
|
||||
});
|
||||
const logProjectDeletionError = (userId: string, projectId: string, error: unknown) => {
|
||||
logger.error({ error, userId, projectId }, "Workspace deletion failed");
|
||||
};
|
||||
|
||||
export const deleteProjectAction = authenticatedActionClient.inputSchema(ZProjectDeleteAction).action(
|
||||
const shouldLogProjectDeletionError = (error: unknown) => {
|
||||
return !(error instanceof Error && isExpectedError(error));
|
||||
};
|
||||
|
||||
export const deleteProjectAction = authenticatedActionClient.inputSchema(z.unknown()).action(
|
||||
withAuditLogging("deleted", "project", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
|
||||
const projectIdForLogging = getProjectIdForLogging(parsedInput);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const availableProjects = (await getUserProjects(ctx.user.id, organizationId)) ?? null;
|
||||
|
||||
if (!!availableProjects && availableProjects?.length <= 1) {
|
||||
throw new Error("You can't delete the last project in the environment.");
|
||||
try {
|
||||
return await deleteProjectWithConfirmation({
|
||||
input: parsedInput,
|
||||
userId: ctx.user.id,
|
||||
auditLoggingCtx: ctx.auditLoggingCtx,
|
||||
});
|
||||
} catch (error) {
|
||||
if (shouldLogProjectDeletionError(error)) {
|
||||
logProjectDeletionError(ctx.user.id, projectIdForLogging, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = parsedInput.projectId;
|
||||
ctx.auditLoggingCtx.oldObject = await getProject(parsedInput.projectId);
|
||||
|
||||
// delete project
|
||||
return await deleteProject(parsedInput.projectId);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -4,14 +4,17 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { truncate } from "@/lib/utils/strings";
|
||||
import { deleteProjectAction } from "@/modules/projects/settings/general/actions";
|
||||
import { hasMatchingWorkspaceDeleteConfirmation } from "@/modules/projects/settings/general/lib/delete-project-confirmation";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface DeleteProjectRenderProps {
|
||||
isDeleteDisabled: boolean;
|
||||
@@ -30,30 +33,55 @@ export const DeleteProjectRender = ({
|
||||
const router = useRouter();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const handleDeleteProject = async () => {
|
||||
setIsDeleting(true);
|
||||
const deleteProjectResponse = await deleteProjectAction({ projectId: currentProject.id });
|
||||
if (deleteProjectResponse?.data) {
|
||||
if (organizationProjects.length === 1) {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
} else if (organizationProjects.length > 1) {
|
||||
// prevents changing of organization when deleting project
|
||||
const remainingProjects = organizationProjects.filter((project) => project.id !== currentProject.id);
|
||||
const productionEnvironment = remainingProjects[0].environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, productionEnvironment.id);
|
||||
}
|
||||
}
|
||||
toast.success(t("environments.workspace.general.workspace_deleted_successfully"));
|
||||
router.push("/");
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteProjectResponse);
|
||||
toast.error(errorMessage);
|
||||
setIsDeleteDialogOpen(false);
|
||||
const [confirmationName, setConfirmationName] = useState("");
|
||||
const hasValidConfirmation = hasMatchingWorkspaceDeleteConfirmation(confirmationName, currentProject.name);
|
||||
|
||||
const handleDeleteDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setConfirmationName("");
|
||||
}
|
||||
setIsDeleteDialogOpen(open);
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!hasValidConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const deleteProjectResponse = await deleteProjectAction({
|
||||
projectId: currentProject.id,
|
||||
confirmationName,
|
||||
});
|
||||
|
||||
if (deleteProjectResponse?.data) {
|
||||
if (organizationProjects.length === 1) {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
} else if (organizationProjects.length > 1) {
|
||||
// prevents changing of organization when deleting project
|
||||
const remainingProject = organizationProjects.find((project) => project.id !== currentProject.id);
|
||||
const productionEnvironment = remainingProject?.environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, productionEnvironment.id);
|
||||
}
|
||||
}
|
||||
toast.success(t("environments.workspace.general.workspace_deleted_successfully"));
|
||||
router.push("/");
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteProjectResponse);
|
||||
logger.error({ errorMessage, projectId: currentProject.id }, "Workspace deletion action failed");
|
||||
toast.error(errorMessage);
|
||||
handleDeleteDialogOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, projectId: currentProject.id }, "Workspace deletion failed");
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -91,13 +119,36 @@ export const DeleteProjectRender = ({
|
||||
<DeleteDialog
|
||||
deleteWhat={t("environments.settings.domain.workspace")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
setOpen={handleDeleteDialogOpenChange}
|
||||
onDelete={handleDeleteProject}
|
||||
text={t("environments.workspace.general.delete_workspace_confirmation", {
|
||||
projectName: truncate(currentProject.name, 30),
|
||||
})}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
disabled={!hasValidConfirmation}>
|
||||
<div className="py-5">
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await handleDeleteProject();
|
||||
}}>
|
||||
<label htmlFor="deleteProjectConfirmation">
|
||||
{t("environments.workspace.general.delete_workspace_confirmation_name", {
|
||||
projectName: currentProject.name,
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
value={confirmationName}
|
||||
onChange={(e) => setConfirmationName(e.target.value)}
|
||||
placeholder={currentProject.name}
|
||||
className="mt-2"
|
||||
type="text"
|
||||
id="deleteProjectConfirmation"
|
||||
name="deleteProjectConfirmation"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</DeleteDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hasMatchingWorkspaceDeleteConfirmation } from "./delete-project-confirmation";
|
||||
|
||||
describe("workspace delete confirmation", () => {
|
||||
test("accepts an exact workspace name match", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("Acme Workspace", "Acme Workspace")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts different casing", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("acme workspace", "Acme Workspace")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts leading and trailing whitespace", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation(" Acme Workspace ", "Acme Workspace")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an empty confirmation", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("", "Acme Workspace")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects mismatched confirmations", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("Other Workspace", "Acme Workspace")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export const WORKSPACE_DELETE_CONFIRMATION_ERROR = "Workspace name confirmation does not match";
|
||||
|
||||
const normalizeWorkspaceNameConfirmation = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
export const hasMatchingWorkspaceDeleteConfirmation = (
|
||||
confirmationName: string,
|
||||
workspaceName: string
|
||||
): boolean => {
|
||||
return (
|
||||
normalizeWorkspaceNameConfirmation(confirmationName) === normalizeWorkspaceNameConfirmation(workspaceName)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
AuthorizationError,
|
||||
InvalidInputError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
} from "@formbricks/types/errors";
|
||||
import {
|
||||
DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR,
|
||||
deleteProjectWithConfirmation,
|
||||
getProjectIdForLogging,
|
||||
} from "./delete-project";
|
||||
import { WORKSPACE_DELETE_CONFIRMATION_ERROR } from "./delete-project-confirmation";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
deleteProject: vi.fn(),
|
||||
getProject: vi.fn(),
|
||||
getUserProjects: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getProject: mocks.getProject,
|
||||
getUserProjects: mocks.getUserProjects,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/projects/settings/lib/project", () => ({
|
||||
deleteProject: mocks.deleteProject,
|
||||
}));
|
||||
|
||||
const baseProject = {
|
||||
id: "cmproject000000000000000000",
|
||||
name: "Acme Workspace",
|
||||
organizationId: "cmorg00000000000000000000",
|
||||
};
|
||||
|
||||
const userId = "cmuser00000000000000000000";
|
||||
|
||||
const callDeleteProjectWithConfirmation = (input = {}) =>
|
||||
deleteProjectWithConfirmation({
|
||||
input: {
|
||||
projectId: baseProject.id,
|
||||
confirmationName: baseProject.name,
|
||||
...input,
|
||||
},
|
||||
userId,
|
||||
auditLoggingCtx: {},
|
||||
});
|
||||
|
||||
describe("deleteProjectWithConfirmation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
|
||||
mocks.getProject.mockResolvedValue(baseProject);
|
||||
mocks.getUserProjects.mockResolvedValue([baseProject, { ...baseProject, id: "cmproject2" }]);
|
||||
mocks.deleteProject.mockResolvedValue(baseProject);
|
||||
});
|
||||
|
||||
test("deletes a workspace when the confirmation name matches", async () => {
|
||||
const auditLoggingCtx = {};
|
||||
|
||||
const result = await deleteProjectWithConfirmation({
|
||||
input: {
|
||||
projectId: baseProject.id,
|
||||
confirmationName: "acme workspace",
|
||||
},
|
||||
userId,
|
||||
auditLoggingCtx,
|
||||
});
|
||||
|
||||
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId,
|
||||
organizationId: baseProject.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.getUserProjects).toHaveBeenCalledWith(userId, baseProject.organizationId);
|
||||
expect(mocks.deleteProject).toHaveBeenCalledWith(baseProject.id);
|
||||
expect(auditLoggingCtx).toMatchObject({
|
||||
organizationId: baseProject.organizationId,
|
||||
projectId: baseProject.id,
|
||||
oldObject: baseProject,
|
||||
});
|
||||
expect(result).toEqual(baseProject);
|
||||
});
|
||||
|
||||
test("rejects invalid input before any project lookup", async () => {
|
||||
await expect(
|
||||
deleteProjectWithConfirmation({
|
||||
input: {},
|
||||
userId,
|
||||
auditLoggingCtx: {},
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
await expect(
|
||||
deleteProjectWithConfirmation({
|
||||
input: {},
|
||||
userId,
|
||||
auditLoggingCtx: {},
|
||||
})
|
||||
).rejects.toThrow(DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR);
|
||||
|
||||
expect(mocks.getProject).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete when the confirmation name does not match", async () => {
|
||||
await expect(callDeleteProjectWithConfirmation({ confirmationName: "Other Workspace" })).rejects.toThrow(
|
||||
InvalidInputError
|
||||
);
|
||||
await expect(callDeleteProjectWithConfirmation({ confirmationName: "Other Workspace" })).rejects.toThrow(
|
||||
WORKSPACE_DELETE_CONFIRMATION_ERROR
|
||||
);
|
||||
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete when the workspace cannot be found", async () => {
|
||||
mocks.getProject.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(ResourceNotFoundError);
|
||||
|
||||
expect(mocks.checkAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete when authorization fails", async () => {
|
||||
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(AuthorizationError);
|
||||
|
||||
expect(mocks.getUserProjects).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete the last available workspace", async () => {
|
||||
mocks.getUserProjects.mockResolvedValueOnce([baseProject]);
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(OperationNotAllowedError);
|
||||
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rethrows downstream delete failures", async () => {
|
||||
const error = new Error("delete failed");
|
||||
mocks.deleteProject.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectIdForLogging", () => {
|
||||
test("returns the project id when present", () => {
|
||||
expect(getProjectIdForLogging({ projectId: baseProject.id })).toBe(baseProject.id);
|
||||
});
|
||||
|
||||
test("returns unknown when the project id is missing or invalid", () => {
|
||||
expect(getProjectIdForLogging({})).toBe("unknown");
|
||||
expect(getProjectIdForLogging({ projectId: 123 })).toBe("unknown");
|
||||
expect(getProjectIdForLogging(null)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getProject, getUserProjects } from "@/lib/project/service";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { deleteProject } from "@/modules/projects/settings/lib/project";
|
||||
import {
|
||||
WORKSPACE_DELETE_CONFIRMATION_ERROR,
|
||||
hasMatchingWorkspaceDeleteConfirmation,
|
||||
} from "./delete-project-confirmation";
|
||||
|
||||
const ZProjectDeleteAction = z.object({
|
||||
projectId: ZId,
|
||||
confirmationName: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR =
|
||||
"Workspace name confirmation is required to delete this workspace.";
|
||||
|
||||
export const parseProjectDeleteActionInput = (input: unknown) => {
|
||||
const parsedInput = ZProjectDeleteAction.safeParse(input);
|
||||
|
||||
if (!parsedInput.success) {
|
||||
throw new InvalidInputError(DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR);
|
||||
}
|
||||
|
||||
return parsedInput.data;
|
||||
};
|
||||
|
||||
export const getProjectIdForLogging = (input: unknown) => {
|
||||
if (typeof input !== "object" || input === null || !("projectId" in input)) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const projectId = input.projectId;
|
||||
|
||||
return typeof projectId === "string" ? projectId : "unknown";
|
||||
};
|
||||
|
||||
const assertMatchingWorkspaceDeleteConfirmation = (confirmationName: string, workspaceName: string) => {
|
||||
if (!hasMatchingWorkspaceDeleteConfirmation(confirmationName, workspaceName)) {
|
||||
throw new InvalidInputError(WORKSPACE_DELETE_CONFIRMATION_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
interface DeleteProjectWithConfirmationParams {
|
||||
input: unknown;
|
||||
userId: string;
|
||||
auditLoggingCtx: {
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
oldObject?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export const deleteProjectWithConfirmation = async ({
|
||||
input,
|
||||
userId,
|
||||
auditLoggingCtx,
|
||||
}: DeleteProjectWithConfirmationParams) => {
|
||||
const { confirmationName, projectId } = parseProjectDeleteActionInput(input);
|
||||
const project = await getProject(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError("project", projectId);
|
||||
}
|
||||
|
||||
const organizationId = project.organizationId;
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const availableProjects = await getUserProjects(userId, organizationId);
|
||||
|
||||
if (availableProjects.length <= 1) {
|
||||
throw new OperationNotAllowedError("You can't delete the last project in the environment.");
|
||||
}
|
||||
|
||||
assertMatchingWorkspaceDeleteConfirmation(confirmationName, project.name);
|
||||
|
||||
auditLoggingCtx.organizationId = organizationId;
|
||||
auditLoggingCtx.projectId = projectId;
|
||||
auditLoggingCtx.oldObject = project;
|
||||
|
||||
return await deleteProject(projectId);
|
||||
};
|
||||
@@ -1,14 +1,29 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getHasNoOrganizations, 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 (session || !isFreshInstance) {
|
||||
if (!isFreshInstance) {
|
||||
const hasNoOrganizations = await getHasNoOrganizations();
|
||||
|
||||
if (hasNoOrganizations) {
|
||||
if (session) {
|
||||
return redirect("/setup/organization/create");
|
||||
}
|
||||
|
||||
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
|
||||
}
|
||||
|
||||
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,18 +66,26 @@ 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,
|
||||
question_count: result.questions?.length ?? 0,
|
||||
created_from: parsedInput.createdFrom ?? "template",
|
||||
});
|
||||
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 }
|
||||
);
|
||||
|
||||
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, ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyVariable, 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,6 +28,138 @@ 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).
|
||||
@@ -59,6 +191,7 @@ 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,
|
||||
@@ -69,7 +202,7 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(survey.id),
|
||||
projectId,
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -100,6 +233,14 @@ 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;
|
||||
@@ -148,23 +289,39 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
if (POSTHOG_KEY && result.status !== "draft") {
|
||||
const isPublish = oldObject?.status === "draft" && result.status === "inProgress";
|
||||
const projectId = await getProjectIdFromSurveyId(parsedInput.id);
|
||||
|
||||
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,
|
||||
};
|
||||
captureSurveyEditDiffEvents(oldObject, result, {
|
||||
userId: ctx.user.id,
|
||||
surveyId: result.id,
|
||||
organizationId,
|
||||
workspaceId: projectId,
|
||||
environmentId: result.environmentId,
|
||||
});
|
||||
|
||||
if (isPublish) {
|
||||
capturePostHogEvent(ctx.user.id, "survey_published", posthogEventMetadata);
|
||||
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata);
|
||||
} else {
|
||||
capturePostHogEvent(ctx.user.id, "survey_updated", posthogEventMetadata);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,9 +492,27 @@ 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;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -133,11 +134,13 @@ 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(survey, languageCode) ? "rtl" : "auto";
|
||||
}, [languageCode, survey]);
|
||||
return isRTLLanguage(jsSurvey, languageCode) ? "rtl" : "auto";
|
||||
}, [languageCode, jsSurvey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -169,7 +172,7 @@ export const SurveyClientWrapper = ({
|
||||
appUrl={publicDomain}
|
||||
environmentId={survey.environmentId}
|
||||
isPreviewMode={isPreview}
|
||||
survey={survey}
|
||||
survey={jsSurvey}
|
||||
styling={styling}
|
||||
languageCode={languageCode}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 {
|
||||
@@ -50,6 +51,18 @@ 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,6 +10,7 @@ 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,
|
||||
@@ -272,7 +273,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={survey}
|
||||
survey={toJsEnvironmentStateSurvey(survey)}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
@@ -303,7 +304,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
languageCode={languageCode}
|
||||
responseCount={42}
|
||||
@@ -388,7 +389,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={survey}
|
||||
survey={toJsEnvironmentStateSurvey(survey)}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
@@ -423,7 +424,7 @@ export const PreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
languageCode={languageCode}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -178,7 +179,7 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={{ ...survey, type: "app" }}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "app" })}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
@@ -205,7 +206,7 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
survey={toJsEnvironmentStateSurvey({ ...survey, type: "link" })}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={async (file) => file.name}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
const createEnvironmentData = (type: "development" | "production") => ({
|
||||
type,
|
||||
attributeKeys: {
|
||||
create: [
|
||||
{
|
||||
name: "Email",
|
||||
key: "email",
|
||||
isUnique: true,
|
||||
type: "default" as const,
|
||||
},
|
||||
{
|
||||
name: "First Name",
|
||||
key: "firstName",
|
||||
isUnique: false,
|
||||
type: "default" as const,
|
||||
},
|
||||
{
|
||||
name: "Last Name",
|
||||
key: "lastName",
|
||||
isUnique: false,
|
||||
type: "default" as const,
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
key: "userId",
|
||||
isUnique: true,
|
||||
type: "default" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const getProjectForEmail = async (email: string) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
memberships: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
organization: {
|
||||
select: {
|
||||
projects: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const membership = user?.memberships[0];
|
||||
const project = membership?.organization.projects[0];
|
||||
const productionEnvironment = project?.environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
|
||||
if (!membership || !project || !productionEnvironment) {
|
||||
throw new Error(`Project not found for email: ${email}`);
|
||||
}
|
||||
|
||||
return {
|
||||
organizationId: membership.organizationId,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
productionEnvironmentId: productionEnvironment.id,
|
||||
};
|
||||
};
|
||||
|
||||
test("requires project name confirmation before deleting a project", async ({ page, users }) => {
|
||||
const timestamp = Date.now();
|
||||
const email = `project-delete-${timestamp}@example.com`;
|
||||
const projectName = `Delete Project ${timestamp}`;
|
||||
const remainingProjectName = `Remaining Project ${timestamp}`;
|
||||
const user = await users.create({
|
||||
email,
|
||||
name: `project-delete-${timestamp}`,
|
||||
projectName,
|
||||
});
|
||||
const project = await getProjectForEmail(email);
|
||||
const remainingProject = await prisma.project.create({
|
||||
data: {
|
||||
name: remainingProjectName,
|
||||
organizationId: project.organizationId,
|
||||
environments: {
|
||||
create: [createEnvironmentData("development"), createEnvironmentData("production")],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const remainingProductionEnvironment = await prisma.environment.findFirst({
|
||||
where: {
|
||||
projectId: remainingProject.id,
|
||||
type: "production",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const remainingProductionEnvironmentId = remainingProductionEnvironment?.id;
|
||||
|
||||
if (!remainingProductionEnvironmentId) {
|
||||
throw new Error("Remaining project production environment not found");
|
||||
}
|
||||
|
||||
await user.login();
|
||||
await page.goto(`/environments/${project.productionEnvironmentId}/workspace/general`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeDisabled();
|
||||
|
||||
await page.locator("#deleteProjectConfirmation").fill(project.projectName.toUpperCase());
|
||||
await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled();
|
||||
await dialog.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Workspace deleted successfully", { exact: true })).toBeVisible();
|
||||
await page.waitForURL(new RegExp(`/environments/${remainingProductionEnvironmentId}/surveys`));
|
||||
await expect.poll(async () => prisma.project.findUnique({ where: { id: project.projectId } })).toBeNull();
|
||||
});
|
||||
@@ -18,7 +18,7 @@ metadata:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.deployment.replicas }}
|
||||
{{- if and (not .Values.autoscaling.enabled) (not (kindIs "invalid" .Values.deployment.replicas)) }}
|
||||
replicas: {{ .Values.deployment.replicas }}
|
||||
{{- end }}
|
||||
selector:
|
||||
|
||||
@@ -83,7 +83,7 @@ deployment:
|
||||
# Additional pod annotations
|
||||
additionalPodAnnotations: {}
|
||||
|
||||
# Number of replicas
|
||||
# Number of replicas when autoscaling is disabled
|
||||
replicas: 1
|
||||
|
||||
# Image pull secrets for private container registries
|
||||
|
||||
@@ -96,7 +96,6 @@
|
||||
"xm-and-surveys/surveys/link-surveys/data-prefilling",
|
||||
"xm-and-surveys/surveys/link-surveys/embed-surveys",
|
||||
"xm-and-surveys/surveys/link-surveys/link-settings",
|
||||
"xm-and-surveys/surveys/link-surveys/pretty-url",
|
||||
"xm-and-surveys/surveys/link-surveys/personal-links",
|
||||
"xm-and-surveys/surveys/link-surveys/single-use-links",
|
||||
"xm-and-surveys/surveys/link-surveys/source-tracking",
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
title: "Pretty URL"
|
||||
description: "Create a custom, memorable URL for your survey instead of sharing a long auto-generated link."
|
||||
icon: "link"
|
||||
---
|
||||
|
||||
<Note>
|
||||
**Self-Hosted Only**: Pretty URLs are available exclusively on self-hosted Formbricks instances. This feature is not available on Formbricks Cloud.
|
||||
</Note>
|
||||
|
||||
## What is a Pretty URL?
|
||||
|
||||
By default, every survey is accessible at a URL containing its auto-generated ID, e.g. `yourdomain.com/s/cm1abc123xyz`. A Pretty URL lets you replace that with a short, human-readable slug of your choice:
|
||||
|
||||
```
|
||||
yourdomain.com/p/customer-feedback
|
||||
```
|
||||
|
||||
When someone visits the pretty URL, they are automatically redirected to the actual survey. Query parameters such as `suId` and `lang` are forwarded as well.
|
||||
|
||||
## Setting Up a Pretty URL
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the Share Modal">
|
||||
Navigate to your survey's **Summary** page and click the **Share survey** button in the top toolbar.
|
||||
</Step>
|
||||
|
||||
<Step title="Go to the Pretty URL tab">
|
||||
In the Share Modal, select the **Pretty URL** tab.
|
||||
</Step>
|
||||
|
||||
<Step title="Enter a slug">
|
||||
Type your desired slug in the input field. Slugs may only contain **lowercase letters, numbers, and hyphens** (e.g. `customer-feedback`, `q4-nps-2024`).
|
||||
|
||||
The full URL is shown in real time below the input so you can confirm how it will look.
|
||||
</Step>
|
||||
|
||||
<Step title="Save">
|
||||
Click **Save**. The slug is now live. Anyone visiting the pretty URL is immediately redirected to your survey.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Managing Pretty URLs
|
||||
|
||||
Once a slug is saved, the Pretty URL tab shows the active link with two actions:
|
||||
|
||||
- **Copy**: copies the full pretty URL to your clipboard.
|
||||
- **Remove**: deletes the slug (after a confirmation prompt). The survey remains accessible via its original `/s/[surveyId]` URL.
|
||||
|
||||
## Viewing All Pretty URLs in Your Organization
|
||||
|
||||
All surveys that have a pretty URL assigned are listed in one place:
|
||||
|
||||
1. Go to **Organization Settings → Domain**.
|
||||
2. Open the **Pretty URLs** section.
|
||||
|
||||
The table shows each survey's name, workspace, slug, and environment type (production / development).
|
||||
|
||||
## Slug Rules
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Characters | Lowercase letters (a-z), digits (0-9), and hyphens (-) |
|
||||
| Uniqueness | Must be unique across your entire Formbricks instance |
|
||||
| Format example | `customer-feedback`, `onboarding-survey`, `q4-nps` |
|
||||
|
||||
## Query Parameter Forwarding
|
||||
|
||||
Pretty URLs forward all query parameters to the destination survey URL. For example:
|
||||
|
||||
```
|
||||
/p/customer-feedback?suId=contact123&lang=de
|
||||
```
|
||||
|
||||
redirects to:
|
||||
|
||||
```
|
||||
/s/[surveyId]?suId=contact123&lang=de
|
||||
```
|
||||
|
||||
This means features like [single-use links](/xm-and-surveys/surveys/link-surveys/single-use-links), [data prefilling](/xm-and-surveys/surveys/link-surveys/data-prefilling), and [multi-language surveys](/xm-and-surveys/surveys/general-features/multi-language-surveys) all work with pretty URLs.
|
||||
@@ -243,9 +243,9 @@ export const setup = async (
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
const surveyIds = filteredSurveys.map((s) => s.id);
|
||||
logger.debug(
|
||||
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
|
||||
`${surveyIds.length.toString()} surveys could be shown to current user on trigger: ${surveyIds.join(", ")}`
|
||||
);
|
||||
} catch {
|
||||
logger.debug("Error during sync. Please try again.");
|
||||
@@ -303,9 +303,9 @@ export const setup = async (
|
||||
filteredSurveys,
|
||||
});
|
||||
|
||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
||||
const surveyIds = filteredSurveys.map((s) => s.id);
|
||||
logger.debug(
|
||||
`${surveyNames.length.toString()} surveys could be shown to current user on trigger: ${surveyNames.join(", ")}`
|
||||
`${surveyIds.length.toString()} surveys could be shown to current user on trigger: ${surveyIds.join(", ")}`
|
||||
);
|
||||
} catch (e) {
|
||||
await handleErrorOnFirstSetup(e as { code: string; responseMessage: string });
|
||||
|
||||
@@ -279,6 +279,52 @@ describe("utils.ts", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("anonymous user: excludes segment-targeted surveys (new shape: hasFilters=true)", () => {
|
||||
environment.data.surveys = [
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId1,
|
||||
segment: { id: mockSegmentId1, hasFilters: true },
|
||||
displayOption: "respondMultiple",
|
||||
} as TEnvironmentStateSurvey,
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId2,
|
||||
segment: { id: mockSegmentId2, hasFilters: false },
|
||||
displayOption: "respondMultiple",
|
||||
} as TEnvironmentStateSurvey,
|
||||
];
|
||||
|
||||
const result = filterSurveys(environment, user);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(mockSurveyId2);
|
||||
});
|
||||
|
||||
test("anonymous user: excludes segment-targeted surveys when cached payload uses legacy shape (filters array)", () => {
|
||||
// Simulates a localStorage payload written by an older SDK version that
|
||||
// still has `segment.filters` and no `hasFilters`. The defensive check
|
||||
// must fall back to the legacy shape so anonymous users don't receive
|
||||
// segment-targeted surveys.
|
||||
environment.data.surveys = [
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId1,
|
||||
segment: { id: mockSegmentId1, filters: [{ type: "attribute", value: "plan" }] },
|
||||
displayOption: "respondMultiple",
|
||||
} as unknown as TEnvironmentStateSurvey,
|
||||
{
|
||||
...baseSurvey,
|
||||
id: mockSurveyId2,
|
||||
segment: { id: mockSegmentId2, filters: [] },
|
||||
displayOption: "respondMultiple",
|
||||
} as unknown as TEnvironmentStateSurvey,
|
||||
];
|
||||
|
||||
const result = filterSurveys(environment, user);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(mockSurveyId2);
|
||||
});
|
||||
|
||||
test("filters by segment if userId is set and user has segments", () => {
|
||||
user.data.userId = "user_abc";
|
||||
user.data.segments = [mockSegmentId1];
|
||||
|
||||
@@ -53,6 +53,19 @@ export const wrapThrowsAsync =
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect whether a survey's segment has filters. Handles both the current
|
||||
* minimal shape (`{ id, hasFilters }`) and the legacy shape with a `filters`
|
||||
* array — older SDKs cached the full segment in localStorage and may still be
|
||||
* read back here within the cache window after an SDK upgrade.
|
||||
*/
|
||||
export const surveyHasSegmentFilters = (survey: TEnvironmentStateSurvey): boolean => {
|
||||
const segment = survey.segment as { hasFilters?: boolean; filters?: unknown[] } | null | undefined;
|
||||
if (!segment) return false;
|
||||
if (typeof segment.hasFilters === "boolean") return segment.hasFilters;
|
||||
return Array.isArray(segment.filters) && segment.filters.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters surveys based on the displayOption, recontactDays, and segments
|
||||
* @param environmentSate - The environment state
|
||||
@@ -123,8 +136,7 @@ export const filterSurveys = (
|
||||
if (!userId) {
|
||||
// exclude surveys that have a segment with filters
|
||||
return filteredSurveys.filter((survey) => {
|
||||
const segmentFiltersLength = survey.segment?.filters.length ?? 0;
|
||||
return segmentFiltersLength === 0;
|
||||
return !surveyHasSegmentFilters(survey);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ export const mockEnvironmentId = "n48a66c01dz05k1297vq06pu";
|
||||
|
||||
export const mockSurvey: TEnvironmentStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: "Test Survey",
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
timeToFinish: false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import type * as CommonUtils from "@/lib/common/utils";
|
||||
import { filterSurveys, getLanguageCode, shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
|
||||
import { mockSurvey } from "@/lib/survey/tests/__mocks__/widget.mock";
|
||||
import * as widget from "@/lib/survey/widget";
|
||||
@@ -34,14 +35,18 @@ vi.mock("@/lib/common/timeout-stack", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/common/utils", () => ({
|
||||
filterSurveys: vi.fn(),
|
||||
getLanguageCode: vi.fn(),
|
||||
getStyling: vi.fn(),
|
||||
shouldDisplayBasedOnPercentage: vi.fn(),
|
||||
wrapThrowsAsync: vi.fn(),
|
||||
handleHiddenFields: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/common/utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof CommonUtils>();
|
||||
return {
|
||||
...actual,
|
||||
filterSurveys: vi.fn(),
|
||||
getLanguageCode: vi.fn(),
|
||||
getStyling: vi.fn(),
|
||||
shouldDisplayBasedOnPercentage: vi.fn(),
|
||||
wrapThrowsAsync: vi.fn(),
|
||||
handleHiddenFields: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUpdateQueue = {
|
||||
hasPendingWork: vi.fn().mockReturnValue(false),
|
||||
@@ -67,7 +72,6 @@ describe("widget-file", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
// @ts-expect-error -- cleaning up mock
|
||||
delete window.formbricksSurveys;
|
||||
|
||||
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
|
||||
@@ -89,7 +93,7 @@ describe("widget-file", () => {
|
||||
await widget.triggerSurvey(mockSurvey);
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
`Survey display of "${mockSurvey.name}" skipped based on displayPercentage.`
|
||||
`Survey display of "${mockSurvey.id}" skipped based on displayPercentage.`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -145,7 +149,7 @@ describe("widget-file", () => {
|
||||
await widget.renderWidget(mockSurvey);
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
`Delaying survey "${mockSurvey.name}" by ${mockSurvey.delay.toString()} seconds.`
|
||||
`Delaying survey "${mockSurvey.id}" by ${mockSurvey.delay.toString()} seconds.`
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(mockSurvey.delay * 1000);
|
||||
@@ -209,7 +213,7 @@ describe("widget-file", () => {
|
||||
await widget.renderWidget(mockSurveyNoDelay as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
`Survey "${mockSurvey.name}" is not available in specified language.`
|
||||
`Survey "${mockSurvey.id}" is not available in specified language.`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -456,7 +460,7 @@ describe("widget-file", () => {
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
segment: { id: "seg_1", filters: [{ type: "attribute", value: "plan" }] },
|
||||
segment: { id: "seg_1", hasFilters: true },
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getStyling,
|
||||
handleHiddenFields,
|
||||
shouldDisplayBasedOnPercentage,
|
||||
surveyHasSegmentFilters,
|
||||
} from "@/lib/common/utils";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
|
||||
@@ -32,7 +33,7 @@ export const triggerSurvey = async (
|
||||
if (survey.displayPercentage) {
|
||||
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
|
||||
if (!shouldDisplaySurvey) {
|
||||
logger.debug(`Survey display of "${survey.name}" skipped based on displayPercentage.`);
|
||||
logger.debug(`Survey display of "${survey.id}" skipped based on displayPercentage.`);
|
||||
return; // skip displaying the survey
|
||||
}
|
||||
}
|
||||
@@ -67,7 +68,7 @@ export const renderWidget = async (
|
||||
logger.debug("Waiting for pending user identification before rendering survey");
|
||||
const identificationSucceeded = await updateQueue.waitForPendingWork();
|
||||
if (!identificationSucceeded) {
|
||||
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
|
||||
const hasSegmentFilters = surveyHasSegmentFilters(survey);
|
||||
|
||||
if (hasSegmentFilters) {
|
||||
logger.debug("User identification failed. Skipping survey with segment filters.");
|
||||
@@ -80,7 +81,7 @@ export const renderWidget = async (
|
||||
}
|
||||
|
||||
if (survey.delay) {
|
||||
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
|
||||
logger.debug(`Delaying survey "${survey.id}" by ${survey.delay.toString()} seconds.`);
|
||||
}
|
||||
|
||||
const { project } = config.get().environment.data;
|
||||
@@ -93,7 +94,7 @@ export const renderWidget = async (
|
||||
const displayLanguage = getLanguageCode(survey, language);
|
||||
//if survey is not available in selected language, survey wont be shown
|
||||
if (!displayLanguage) {
|
||||
logger.debug(`Survey "${survey.name}" is not available in specified language.`);
|
||||
logger.debug(`Survey "${survey.id}" is not available in specified language.`);
|
||||
setIsSurveyRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies -- required for Prisma types */
|
||||
import type { ActionClass, Language, Project, Segment, Survey, SurveyLanguage } from "@prisma/client";
|
||||
import type { ActionClass, Language, Project, Survey, SurveyLanguage } from "@prisma/client";
|
||||
|
||||
export type TEnvironmentStateSurvey = Pick<
|
||||
Survey,
|
||||
| "id"
|
||||
| "name"
|
||||
// name intentionally omitted — internal label, not needed by SDK
|
||||
| "welcomeCard"
|
||||
| "questions"
|
||||
| "variables"
|
||||
@@ -25,7 +25,8 @@ export type TEnvironmentStateSurvey = Pick<
|
||||
> & {
|
||||
languages: (SurveyLanguage & { language: Language })[];
|
||||
triggers: { actionClass: ActionClass }[];
|
||||
segment?: Segment;
|
||||
// Minimal segment shape — full filter logic is evaluated server-side and must not reach the browser
|
||||
segment?: { id: string; hasFilters: boolean };
|
||||
displayPercentage: number;
|
||||
type: "link" | "app";
|
||||
styling?: TSurveyStyling;
|
||||
|
||||
@@ -67,12 +67,15 @@ function OpenText({
|
||||
);
|
||||
};
|
||||
|
||||
const descriptionId = description ? `${inputId}-description` : undefined;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
descriptionId={descriptionId}
|
||||
required={required}
|
||||
requiredLabel={requiredLabel}
|
||||
htmlFor={inputId}
|
||||
@@ -90,6 +93,7 @@ function OpenText({
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-required={required}
|
||||
aria-describedby={descriptionId}
|
||||
dir={dir}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
@@ -105,6 +109,7 @@ function OpenText({
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-required={required}
|
||||
aria-describedby={descriptionId}
|
||||
dir={dir}
|
||||
disabled={disabled}
|
||||
errorMessage={errorMessage}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn, stripInlineStyles } from "@/lib/utils";
|
||||
interface ElementHeaderProps extends React.ComponentProps<"div"> {
|
||||
headline: string;
|
||||
description?: string;
|
||||
descriptionId?: string;
|
||||
required?: boolean;
|
||||
/** Custom label for the required indicator. Defaults to "Required" */
|
||||
requiredLabel?: string;
|
||||
@@ -44,6 +45,7 @@ const isValidHTML = (str: string): boolean => {
|
||||
function ElementHeader({
|
||||
headline,
|
||||
description,
|
||||
descriptionId,
|
||||
required = false,
|
||||
requiredLabel = "Required",
|
||||
htmlFor,
|
||||
@@ -91,7 +93,7 @@ function ElementHeader({
|
||||
|
||||
{/* Description/Subheader */}
|
||||
{description ? (
|
||||
<Label htmlFor={htmlFor} variant="description">
|
||||
<Label id={descriptionId} variant="description">
|
||||
{description}
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "اختر خيارًا",
|
||||
"select_options": "اختر الخيارات",
|
||||
"sending_responses": "جارٍ إرسال الردود...",
|
||||
"survey_dialog": "مربع حوار الاستبيان",
|
||||
"takes_less_than_x_minutes": "{count, plural, zero {يستغرق أقل من دقيقة} one {يستغرق أقل من دقيقة واحدة} two {يستغرق أقل من دقيقتين} few {يستغرق أقل من {count} دقائق} many {يستغرق أقل من {count} دقيقة} other {يستغرق أقل من {count} دقيقة}}",
|
||||
"takes_x_minutes": "{count, plural, zero {يستغرق صفر دقائق} one {يستغرق دقيقة واحدة} two {يستغرق دقيقتين} few {يستغرق {count} دقائق} many {يستغرق {count} دقيقة} other {يستغرق {count} دقيقة}}",
|
||||
"takes_x_plus_minutes": "يستغرق {count}+ دقيقة",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Vælg en mulighed",
|
||||
"select_options": "Vælg muligheder",
|
||||
"sending_responses": "Sender svar…",
|
||||
"survey_dialog": "Undersøgelsesdialog",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Tager mindre end 1 minut} other {Tager mindre end {count} minutter}}",
|
||||
"takes_x_minutes": "{count, plural, one {Tager 1 minut} other {Tager {count} minutter}}",
|
||||
"takes_x_plus_minutes": "Tager {count}+ minutter",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Wähle eine Option",
|
||||
"select_options": "Wähle Optionen",
|
||||
"sending_responses": "Antworten werden gesendet...",
|
||||
"survey_dialog": "Umfragedialog",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Dauert weniger als 1 Minute} other {Dauert weniger als {count} Minuten}}",
|
||||
"takes_x_minutes": "{count, plural, one {Dauert 1 Minute} other {Dauert {count} Minuten}}",
|
||||
"takes_x_plus_minutes": "Dauert {count}+ Minuten",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Select an option",
|
||||
"select_options": "Select options",
|
||||
"sending_responses": "Sending responses…",
|
||||
"survey_dialog": "Survey Dialog",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}",
|
||||
"takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}",
|
||||
"takes_x_plus_minutes": "Takes {count}+ minutes",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Selecciona una opción",
|
||||
"select_options": "Selecciona opciones",
|
||||
"sending_responses": "Enviando respuestas...",
|
||||
"survey_dialog": "Diálogo de encuesta",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Toma menos de 1 minuto} other {Toma menos de {count} minutos}}",
|
||||
"takes_x_minutes": "{count, plural, one {Toma 1 minuto} other {Toma {count} minutos}}",
|
||||
"takes_x_plus_minutes": "Toma {count}+ minutos",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Vali variant",
|
||||
"select_options": "Vali variandid",
|
||||
"sending_responses": "Vastuste saatmine…",
|
||||
"survey_dialog": "Küsitluse dialoog",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Võtab vähem kui 1 minuti} other {Võtab vähem kui {count} minutit}}",
|
||||
"takes_x_minutes": "{count, plural, one {Võtab 1 minuti} other {Võtab {count} minutit}}",
|
||||
"takes_x_plus_minutes": "Võtab {count}+ minutit",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Sélectionner une option",
|
||||
"select_options": "Sélectionner des options",
|
||||
"sending_responses": "Envoi des réponses...",
|
||||
"survey_dialog": "Boîte de dialogue du sondage",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Prend moins d'une minute} other {Prend moins de {count} minutes}}",
|
||||
"takes_x_minutes": "{count, plural, one {Prend 1 minute} other {Prend {count} minutes}}",
|
||||
"takes_x_plus_minutes": "Prend {count}+ minutes",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "एक विकल्प चुनें",
|
||||
"select_options": "विकल्प चुनें",
|
||||
"sending_responses": "प्रतिक्रियाएँ भेज रहे हैं...",
|
||||
"survey_dialog": "सर्वेक्षण संवाद",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {1 मिनट से कम लगता है} other {{count} मिनट से कम लगता है}}",
|
||||
"takes_x_minutes": "{count, plural, one {1 मिनट लगता है} other {{count} मिनट लगते हैं}}",
|
||||
"takes_x_plus_minutes": "{count}+ मिनट लगते हैं",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Lehetőség kiválasztása",
|
||||
"select_options": "Lehetőségek kiválasztása",
|
||||
"sending_responses": "Válaszok küldése…",
|
||||
"survey_dialog": "Kérdőív párbeszédpanel",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Kevesebb mint 1 percet vesz igénybe} other {Kevesebb mint {count} percet vesz igénybe}}",
|
||||
"takes_x_minutes": "{count, plural, one {1 percet vesz igénybe} other {{count} percet vesz igénybe}}",
|
||||
"takes_x_plus_minutes": "{count}+ percet vesz igénybe",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Seleziona un'opzione",
|
||||
"select_options": "Seleziona opzioni",
|
||||
"sending_responses": "Invio risposte in corso...",
|
||||
"survey_dialog": "Finestra di dialogo del sondaggio",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Richiede meno di 1 minuto} other {Richiede meno di {count} minuti}}",
|
||||
"takes_x_minutes": "{count, plural, one {Richiede 1 minuto} other {Richiede {count} minuti}}",
|
||||
"takes_x_plus_minutes": "Richiede più di {count} minuti",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "オプションを選択",
|
||||
"select_options": "オプションを選択",
|
||||
"sending_responses": "回答を送信中...",
|
||||
"survey_dialog": "アンケートダイアログ",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {1分未満} other {{count}分未満}}",
|
||||
"takes_x_minutes": "{count, plural, one {1分} other {{count}分}}",
|
||||
"takes_x_plus_minutes": "{count}分以上",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Selecteer een optie",
|
||||
"select_options": "Selecteer opties",
|
||||
"sending_responses": "Reacties verzenden...",
|
||||
"survey_dialog": "Enquête-dialoogvenster",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Duurt minder dan 1 minuut} other {Duurt minder dan {count} minuten}}",
|
||||
"takes_x_minutes": "{count, plural, one {Duurt 1 minuut} other {Duurt {count} minuten}}",
|
||||
"takes_x_plus_minutes": "Duurt {count}+ minuten",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Selecione uma opção",
|
||||
"select_options": "Selecione opções",
|
||||
"sending_responses": "Enviando respostas...",
|
||||
"survey_dialog": "Caixa de diálogo da pesquisa",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Leva menos de 1 minuto} other {Leva menos de {count} minutos}}",
|
||||
"takes_x_minutes": "{count, plural, one {Leva 1 minuto} other {Leva {count} minutos}}",
|
||||
"takes_x_plus_minutes": "Leva {count}+ minutos",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Selectează o opțiune",
|
||||
"select_options": "Selectează opțiuni",
|
||||
"sending_responses": "Trimiterea răspunsurilor...",
|
||||
"survey_dialog": "Dialog sondaj",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Durează mai puțin de 1 minut} few {Durează mai puțin de {count} minute} other {Durează mai puțin de {count} de minute}}",
|
||||
"takes_x_minutes": "{count, plural, one {Durează 1 minut} few {Durează {count} minute} other {Durează {count} de minute}}",
|
||||
"takes_x_plus_minutes": "Durează peste {count} minute",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user