Compare commits

...

14 Commits

Author SHA1 Message Date
Tiago Farto 2084465bc9 chore: docker compose fix 2026-03-25 14:22:56 +00:00
Tiago Farto 80353d641e chore: docker fixes 2026-03-25 14:11:53 +00:00
Tiago Farto 3ec627cf3c Merge branch 'main' into feat/workflows-service-inngest 2026-03-25 13:57:50 +00:00
Tiago Farto 5fd6ee64c0 chore: inngest workflows poc 2026-03-25 13:55:43 +00:00
Dhruwang Jariwala 20dc147682 fix: scrolling behaviour to invalid questions (#7573) 2026-03-25 13:35:51 +00:00
cursor[bot] 2bb7a6f277 fix: prevent TypeError when checking for duplicate matrix labels (#7579)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-03-25 13:14:18 +00:00
Dhruwang Jariwala deb062dd03 fix: handle 404 race condition in Stripe webhook reconciliation (#7584) 2026-03-25 09:58:00 +00:00
Dhruwang Jariwala 474be86d33 fix: translations for option types (#7576) 2026-03-24 13:18:26 +00:00
Dhruwang Jariwala e7ca66ed77 fix: use TTC data for reliable survey impression counting (#7572) 2026-03-24 08:52:35 +00:00
Matti Nannt 2b49dbecd3 chore: add dev:setup script to generate .env and missing secrets (#7555)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 08:26:32 +00:00
Anshuman Pandey 6da4c6f352 fix: proper errors server side when resources are not found (#7571) 2026-03-24 07:52:37 +00:00
Aryan Ghugare 659b240fca feat: enhance welcome card to support video uploads and display #7491 (#7497)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 07:34:43 +00:00
Dhruwang Jariwala 19c0b1d14d fix: response table settings formatting (#7540)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-24 06:36:45 +00:00
Dhruwang Jariwala b4472f48e9 fix: (Duplicate) prevent multi-language survey buttons from falling back to English (#7559) 2026-03-24 05:45:47 +00:00
143 changed files with 2722 additions and 2432 deletions
+4
View File
@@ -57,6 +57,10 @@ packages/database/migrations
branch.json
.vercel
# Golang
.cache
services/**/bin/
# IntelliJ IDEA
/.idea/
/*.iml
@@ -1,5 +1,6 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
@@ -20,12 +21,12 @@ const Page = async (props: ConnectPageProps) => {
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const channel = project.config.channel || null;
@@ -1,6 +1,7 @@
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
@@ -23,22 +24,22 @@ const Page = async (props: XMTemplatePageProps) => {
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate();
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const projects = await getUserProjects(session.user.id, organizationId);
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = async (props: {
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -36,7 +36,7 @@ const ProjectOnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
return (
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
@@ -28,7 +29,7 @@ const OnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
@@ -1,6 +1,7 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
@@ -45,7 +46,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
throw new ResourceNotFoundError(t("common.team"), null);
}
const publicDomain = getPublicDomain();
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
@@ -17,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
}
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
return (
@@ -2,7 +2,11 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import {
AuthorizationError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
@@ -46,7 +50,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
throw new ResourceNotFoundError("Organization", organizationId);
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
@@ -42,7 +43,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// Validate that project permission exists for members
if (isMember && !projectPermission) {
throw new Error(t("common.workspace_permission_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
return (
@@ -1,4 +1,5 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -20,15 +21,15 @@ const AccountSettingsLayout = async (props: {
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
@@ -146,18 +147,18 @@ const Page = async (props: {
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!memberships) {
throw new Error(t("common.membership_not_found"));
throw new ResourceNotFoundError(t("common.membership"), null);
}
if (user?.notificationSettings) {
@@ -1,3 +1,4 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
@@ -28,7 +29,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user ? await getUser(session.user.id) : null;
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -25,7 +26,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
@@ -1,4 +1,5 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -17,15 +18,15 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
return <>{children}</>;
@@ -300,7 +300,6 @@ export const ResponseTable = ({
<DataTableSettingsModal
open={isTableSettingsModalOpen}
setOpen={setIsTableSettingsModalOpen}
survey={survey}
table={table}
columnOrder={columnOrder}
handleDragEnd={handleDragEnd}
@@ -1,3 +1,4 @@
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -31,15 +32,15 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
]);
if (!survey) {
throw new Error(t("common.survey_not_found"));
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
}
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
@@ -48,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const organizationBilling = await getOrganizationBilling(organization.id);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), organization.id);
}
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
@@ -9,11 +10,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
const t = await getTranslate();
const survey = await getSurvey(surveyId);
if (!survey) {
throw new Error("Survey not found");
throw new ResourceNotFoundError(t("common.survey"), surveyId);
}
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Workspace not found");
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const styling = getStyling(project, survey);
@@ -11,8 +11,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import {
getElementSummary,
getResponsesForSummary,
@@ -44,7 +43,7 @@ vi.mock("@/lib/survey/service", () => ({
}));
vi.mock("@/lib/surveyLogic/utils", () => ({
evaluateLogic: vi.fn(),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
@@ -229,12 +228,6 @@ describe("getSurveySummaryDropOff", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
vi.mocked(performActions).mockReturnValue({
jumpTarget: undefined,
requiredElementIds: [],
calculations: {},
});
});
test("calculates dropOff correctly with welcome card disabled", () => {
@@ -246,7 +239,7 @@ describe("getSurveySummaryDropOff", () => {
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10 },
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
finished: false,
}, // Dropped at q2
{
@@ -269,22 +262,55 @@ describe("getSurveySummaryDropOff", () => {
);
expect(dropOff.length).toBe(2);
// Q1
// Q1: welcome card disabled so impressions = displayCount
expect(dropOff[0].elementId).toBe("q1");
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
expect(dropOff[0].impressions).toBe(displayCount);
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
expect(dropOff[0].ttc).toBe(10);
// Q2
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
expect(dropOff[1].elementId).toBe("q2");
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
expect(dropOff[1].impressions).toBe(2);
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(10);
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
});
test("handles logic jumps", () => {
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
const surveyWithWelcome: TSurvey = {
...surveyWithBlocks,
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
};
const responses = [
{
id: "r1",
data: { q1: "a" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10 }, // Only saw q1, never reached q2
finished: false,
},
] as any;
const displayCount = 1;
const dropOff = getSurveySummaryDropOff(
surveyWithWelcome,
getElementsFromBlocks(surveyWithWelcome.blocks),
responses,
displayCount
);
expect(dropOff[0].impressions).toBe(1); // Saw q1
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
expect(dropOff[1].impressions).toBe(0); // Never saw q2
expect(dropOff[1].dropOffCount).toBe(0);
});
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
const surveyWithLogic: TSurvey = {
...mockBaseSurvey,
blocks: [
@@ -315,36 +341,6 @@ describe("getSurveySummaryDropOff", () => {
charLimit: { enabled: false },
},
] as TSurveyElement[],
logic: [
{
id: "logic1",
conditions: {
id: "condition1",
connector: "and" as const,
conditions: [
{
id: "c1",
leftOperand: {
type: "element" as const,
value: "q2",
},
operator: "equals" as const,
rightOperand: {
type: "static" as const,
value: "b",
},
},
],
},
actions: [
{
id: "action1",
objective: "jumpToBlock" as const,
target: "q4",
},
],
},
],
},
{
id: "block3",
@@ -377,28 +373,21 @@ describe("getSurveySummaryDropOff", () => {
],
questions: [],
};
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
const responses = [
{
id: "r1",
data: { q1: "a", q2: "b" },
data: { q1: "a", q2: "b", q4: "d" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10, q2: 10 },
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
finished: false,
}, // Jumps from q2 to q4, drops at q4
},
];
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
// Simulate logic on q2 triggering
return data.q2 === "b";
});
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
}
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
});
const dropOff = getSurveySummaryDropOff(
surveyWithLogic,
@@ -407,11 +396,11 @@ describe("getSurveySummaryDropOff", () => {
1
);
expect(dropOff[0].impressions).toBe(1); // q1
expect(dropOff[1].impressions).toBe(1); // q2
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
expect(dropOff[0].impressions).toBe(1); // q1: seen
expect(dropOff[1].impressions).toBe(1); // q2: seen
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
});
});
@@ -11,7 +11,6 @@ import {
TResponseData,
TResponseFilterCriteria,
TResponseTtc,
TResponseVariables,
ZResponseFilterCriteria,
} from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
@@ -37,8 +36,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils";
@@ -93,63 +91,13 @@ export const getSurveySummaryMeta = (
};
};
const evaluateLogicAndGetNextElementId = (
localSurvey: TSurvey,
elements: TSurveyElement[],
data: TResponseData,
localVariables: TResponseVariables,
currentElementIndex: number,
currElementTemp: TSurveyElement,
selectedLanguage: string | null
): {
nextElementId: string | undefined;
updatedSurvey: TSurvey;
updatedVariables: TResponseVariables;
} => {
let updatedSurvey = { ...localSurvey };
let updatedVariables = { ...localVariables };
let firstJumpTarget: string | undefined;
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
if (currentBlock?.logic && currentBlock.logic.length > 0) {
for (const logic of currentBlock.logic) {
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
const { jumpTarget, requiredElementIds, calculations } = performActions(
updatedSurvey,
logic.actions,
data,
updatedVariables
);
if (requiredElementIds.length > 0) {
// Update blocks to mark elements as required
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
...block,
elements: block.elements.map((e) =>
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
),
}));
}
updatedVariables = { ...updatedVariables, ...calculations };
if (jumpTarget && !firstJumpTarget) {
firstJumpTarget = jumpTarget;
}
}
}
}
// If no jump target was set, check for a fallback logic
if (!firstJumpTarget && currentBlock?.logicFallback) {
firstJumpTarget = currentBlock.logicFallback;
}
// Return the first jump target if found, otherwise go to the next element
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
return { nextElementId, updatedSurvey, updatedVariables };
// Determine whether a response interacted with a given element.
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
// This is more reliable than replaying survey logic, which can misattribute impressions
// when branching logic skips elements or when partial response data is insufficient
// to evaluate conditions correctly.
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
};
export const getSurveySummaryDropOff = (
@@ -170,16 +118,8 @@ export const getSurveySummaryDropOff = (
let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const surveyVariablesData = survey.variables?.reduce(
(acc, variable) => {
acc[variable.id] = variable.value;
return acc;
},
{} as Record<string, string | number>
);
responses.forEach((response) => {
// Calculate total time-to-completion
// Calculate total time-to-completion per element
Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId];
@@ -187,51 +127,21 @@ export const getSurveySummaryDropOff = (
}
});
let localSurvey = structuredClone(survey);
let localResponseData: TResponseData = { ...response.data };
let localVariables: TResponseVariables = {
...surveyVariablesData,
};
// Count impressions based on actual interaction data (ttc + response data)
// instead of replaying survey logic which is unreliable with branching
let lastSeenIdx = -1;
let currQuesIdx = 0;
while (currQuesIdx < elements.length) {
const currQues = elements[currQuesIdx];
if (!currQues) break;
// element is not answered and required
if (response.data[currQues.id] === undefined && currQues.required) {
dropOffArr[currQuesIdx]++;
impressionsArr[currQuesIdx]++;
break;
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (wasElementSeen(response, element.id)) {
impressionsArr[i]++;
lastSeenIdx = i;
}
}
impressionsArr[currQuesIdx]++;
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
localSurvey,
elements,
localResponseData,
localVariables,
currQuesIdx,
currQues,
response.language
);
localSurvey = updatedSurvey;
localVariables = updatedVariables;
if (nextElementId) {
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
if (!response.data[nextElementId] && !response.finished) {
dropOffArr[nextQuesIdx]++;
impressionsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
} else {
currQuesIdx++;
}
// Attribute drop-off to the last element the respondent interacted with
if (!response.finished && lastSeenIdx >= 0) {
dropOffArr[lastSeenIdx]++;
}
});
@@ -240,6 +150,8 @@ export const getSurveySummaryDropOff = (
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
});
// When the welcome card is disabled, the first element's impressions should equal displayCount
// because every survey display is an impression of the first element
if (!survey.welcomeCard.enabled) {
dropOffArr[0] = displayCount - impressionsArr[0];
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
@@ -251,7 +163,7 @@ export const getSurveySummaryDropOff = (
impressionsArr[0] = displayCount;
} else {
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
}
for (let i = 1; i < elements.length; i++) {
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -32,13 +33,13 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const survey = await getSurvey(params.surveyId);
if (!survey) {
throw new Error(t("common.survey_not_found"));
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
@@ -46,11 +47,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), organizationId);
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
@@ -1,6 +1,7 @@
"use client";
import clsx from "clsx";
import { TFunction } from "i18next";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -54,6 +55,25 @@ export enum OptionsType {
QUOTAS = "Quotas",
}
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
switch (type) {
case OptionsType.ELEMENTS:
return t("common.elements");
case OptionsType.TAGS:
return t("common.tags");
case OptionsType.ATTRIBUTES:
return t("common.attributes");
case OptionsType.OTHERS:
return t("common.other_filters");
case OptionsType.META:
return t("common.meta");
case OptionsType.HIDDEN_FIELDS:
return t("common.hidden_fields");
case OptionsType.QUOTAS:
return t("common.quotas");
}
};
export type ElementOption = {
label: string;
elementType?: TSurveyElementTypeEnum;
@@ -218,7 +238,12 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
{options?.map((data) => (
<Fragment key={data.header}>
{data?.option.length > 0 && (
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
<CommandGroup
heading={
<p className="text-sm font-medium text-slate-600">
{getOptionsTypeTranslationKey(data.header, t)}
</p>
}>
{data?.option?.map((o) => (
<CommandItem
key={o.id}
@@ -1,4 +1,6 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { SurveyContextWrapper } from "./context/survey-context";
interface SurveyLayoutProps {
@@ -10,9 +12,10 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
const resolvedParams = await params;
const survey = await getSurvey(resolvedParams.surveyId);
const t = await getTranslate();
if (!survey) {
throw new Error("Survey not found");
throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId);
}
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
+14 -9
View File
@@ -140,6 +140,7 @@ checksums:
common/connect: 8778ee245078a8be4a2ce855c8c56edc
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
common/connected: aa0ceca574641de34c74b9e590664230
common/contact: 9afa39bc47019ee6dec6c74b6273967c
common/contacts: d5b6c3f890b3904eaf5754081945c03d
common/continue: 3cfba90b4600131e82fc4260c568d044
common/copied: 29208e06d704c4fc4b8b534dc7acc4ef
@@ -187,12 +188,12 @@ checksums:
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/elements: 8cb054d952b341e5965284860d532bc7
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
common/environment: 0844e8dc1485339c8de066dc0a9bb6a1
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
common/environment_notice: 228a8668be1812e031f438d166861729
common/error: 3c95bcb32c2104b99a46f5b3dd015248
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
@@ -256,7 +257,9 @@ checksums:
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/meta: 842eac888f134f3525f8ea613d933687
common/metadata: 695d4f7da261ba76e3be4de495491028
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
@@ -296,10 +299,9 @@ checksums:
common/or: 7b133c38bec0d5ee23cc6bcf9a8de50b
common/organization: 3dc8489af7e74121f65ce6d9677bc94d
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
common/organization_not_found: 4cb8c07ec2c599b6f48750e06ffa182b
common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/organization_teams_not_found: ce29fcb7a4e8b4582f92b65dea9b7d4e
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
@@ -391,7 +393,6 @@ checksums:
common/survey_id: 08303e98b3d4134947256e494b0c829e
common/survey_languages: 93e4a10ab190e6b1e1f7fe5f702df249
common/survey_live: d1f370505c67509e7b2759952daba20d
common/survey_not_found: 0485ea98d13a414eeefc8f1118b9c293
common/survey_paused: c770d174d6b57e8425a54906a09c8b39
common/survey_type: 417fcfecf8eaedefc4f11172426811f9
common/surveys: 33f68ad4111b32a6361beb9d5c184533
@@ -406,7 +407,6 @@ checksums:
common/team_name: 549d949de4b9adad4afd6427a60a329e
common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
@@ -430,7 +430,6 @@ checksums:
common/url: ca97457614226960d41dd18c3c29c86b
common/user: 61073457a5c3901084b557d065f876be
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
common/variable: c13db5775ba9791b1522cc55c9c7acce
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
common/variables: ffd3eec5497af36d7b4e4185bad1313a
@@ -446,14 +445,13 @@ checksums:
common/weeks: 545de30df4f44d3f6d1d344af6a10815
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
common/workspace_id: bafef925e1b57b52a69844fdf47aac3c
common/workspace_name: 14c04a902a874ab5ddbe9cf369ef0414
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
common/workspace_not_found: 038fb0aaf3570610f4377b9eaed13752
common/workspace_permission_not_found: e94bdff8af51175c5767714f82bb4833
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
@@ -629,7 +627,6 @@ checksums:
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
@@ -809,8 +806,14 @@ checksums:
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
environments/integrations/webhooks/endpoint_bad_gateway_error: 48ab17e9a77030b289ec22f497f50b63
environments/integrations/webhooks/endpoint_gateway_timeout_error: 5da45e2f6933927d1f8b0aaa9566e6a6
environments/integrations/webhooks/endpoint_internal_server_error: 6773fc34349febf95475cde88d8ee072
environments/integrations/webhooks/endpoint_method_not_allowed_error: 9963b503311393f4d7bffae9df46d422
environments/integrations/webhooks/endpoint_not_found_error: 607b75b7b7aa92ca81fe44e466f7c318
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
environments/integrations/webhooks/endpoint_service_unavailable_error: f9d4874c322f2963f5afaede354c9416
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
@@ -1608,6 +1611,8 @@ checksums:
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
environments/surveys/edit/reverse_order_occasionally: 170fd50de940f382fa2e605228e4e088
environments/surveys/edit/reverse_order_occasionally_except_last: 1c833001b940f1419dd7534b199a0b4a
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
+4
View File
@@ -38,6 +38,8 @@ export const env = createEnv({
.optional()
.or(z.string().refine((str) => str === "")),
IMPRINT_ADDRESS: z.string().optional(),
INNGEST_BASE_URL: z.url().optional(),
INNGEST_EVENT_KEY: z.string().optional(),
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
CHATWOOT_BASE_URL: z.url().optional(),
@@ -161,6 +163,8 @@ export const env = createEnv({
HTTPS_PROXY: process.env.HTTPS_PROXY,
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INNGEST_BASE_URL: process.env.INNGEST_BASE_URL,
INNGEST_EVENT_KEY: process.env.INNGEST_EVENT_KEY,
INVITE_DISABLED: process.env.INVITE_DISABLED,
CHATWOOT_WEBSITE_TOKEN: process.env.CHATWOOT_WEBSITE_TOKEN,
CHATWOOT_BASE_URL: process.env.CHATWOOT_BASE_URL,
@@ -0,0 +1,113 @@
import { type IncomingMessage, type Server, type ServerResponse, createServer } from "node:http";
import { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
interface CapturedRequest {
method?: string;
url?: string;
headers: IncomingMessage["headers"];
body: string;
}
describe("sendInngestEvents", () => {
let server: Server;
let capturedRequests: CapturedRequest[];
beforeEach(async () => {
capturedRequests = [];
server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
capturedRequests.push({
method: req.method,
url: req.url,
headers: req.headers,
body: Buffer.concat(chunks).toString("utf8"),
});
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: 200, ids: ["evt_1", "evt_2"] }));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
});
afterEach(async () => {
vi.resetModules();
vi.doUnmock("@/lib/env");
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
});
test("posts events to the self-hosted event API using the configured event key and timestamp", async () => {
const address = server.address() as AddressInfo;
const baseUrl = `http://127.0.0.1:${address.port}/`;
vi.doMock("@/lib/env", () => ({
env: {
INNGEST_BASE_URL: baseUrl,
INNGEST_EVENT_KEY: "test-event-key",
},
}));
const { resetInngestClientForTests, sendInngestEvents } = await import("./client");
await sendInngestEvents([
{
name: "survey.start",
data: {
surveyId: "survey_1",
environmentId: "env_1",
scheduledFor: "2026-04-01T12:00:00.000Z",
},
ts: 1775044800000,
},
{
name: "survey.end.cancelled",
data: {
surveyId: "survey_1",
environmentId: "env_1",
},
},
]);
resetInngestClientForTests();
expect(capturedRequests).toHaveLength(1);
expect(capturedRequests[0]?.method).toBe("POST");
expect(capturedRequests[0]?.url).toBe("/e/test-event-key");
expect(capturedRequests[0]?.headers["content-type"]).toContain("application/json");
expect(JSON.parse(capturedRequests[0]?.body ?? "[]")).toEqual([
{
name: "survey.start",
data: {
surveyId: "survey_1",
environmentId: "env_1",
scheduledFor: "2026-04-01T12:00:00.000Z",
},
ts: 1775044800000,
},
{
name: "survey.end.cancelled",
data: {
surveyId: "survey_1",
environmentId: "env_1",
},
ts: expect.any(Number),
},
]);
});
});
+79
View File
@@ -0,0 +1,79 @@
import "server-only";
import { Inngest } from "inngest";
import { env } from "@/lib/env";
import { INNGEST_POC_APP_ID } from "./constants";
export interface InngestScheduledEventData {
surveyId: string;
environmentId: string;
scheduledFor: string;
}
export interface InngestCancelledEventData {
surveyId: string;
environmentId: string;
}
export type InngestSendableEvent =
| {
name: string;
data: InngestScheduledEventData;
ts?: number;
}
| {
name: string;
data: InngestCancelledEventData;
ts?: number;
};
interface InngestEventClient {
send: (payload: InngestSendableEvent | InngestSendableEvent[]) => Promise<unknown>;
}
let inngestClient: InngestEventClient | null = null;
const getRequiredEnv = (): { baseUrl: string; eventKey: string } => {
if (!env.INNGEST_BASE_URL) {
throw new Error("INNGEST_BASE_URL is required to publish survey lifecycle events");
}
if (!env.INNGEST_EVENT_KEY) {
throw new Error("INNGEST_EVENT_KEY is required to publish survey lifecycle events");
}
return {
baseUrl: env.INNGEST_BASE_URL,
eventKey: env.INNGEST_EVENT_KEY,
};
};
const createInngestClient = (): InngestEventClient => {
const { baseUrl, eventKey } = getRequiredEnv();
return new Inngest({
id: INNGEST_POC_APP_ID,
baseUrl,
eventKey,
isDev: false,
}) as unknown as InngestEventClient;
};
const getInngestClient = (): InngestEventClient => {
if (!inngestClient) {
inngestClient = createInngestClient();
}
return inngestClient;
};
export const resetInngestClientForTests = (): void => {
inngestClient = null;
};
export const sendInngestEvents = async (events: InngestSendableEvent[]): Promise<unknown> => {
if (events.length === 0) {
return [];
}
return getInngestClient().send(events);
};
+5
View File
@@ -0,0 +1,5 @@
export const INNGEST_POC_APP_ID = "formbricks-inngest-poc";
export const INNGEST_SURVEY_START_EVENT = "survey.start";
export const INNGEST_SURVEY_END_EVENT = "survey.end";
export const INNGEST_SURVEY_START_CANCELLED_EVENT = "survey.start.cancelled";
export const INNGEST_SURVEY_END_CANCELLED_EVENT = "survey.end.cancelled";
@@ -0,0 +1,261 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import {
INNGEST_SURVEY_END_CANCELLED_EVENT,
INNGEST_SURVEY_END_EVENT,
INNGEST_SURVEY_START_CANCELLED_EVENT,
INNGEST_SURVEY_START_EVENT,
} from "./constants";
import {
getSurveyLifecycleCancellationEvents,
getSurveyLifecycleEvents,
publishSurveyLifecycleCancellationEvents,
publishSurveyLifecycleEvents,
} from "./survey-lifecycle";
vi.mock("server-only", () => ({}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("survey lifecycle inngest events", () => {
beforeEach(() => {
vi.mocked(logger.error).mockReset();
});
test("builds a start event when startsAt is set on create", () => {
const startsAt = new Date("2026-04-01T12:00:00.000Z");
expect(
getSurveyLifecycleEvents({
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt,
endsAt: null,
},
now: new Date("2026-03-31T12:00:00.000Z"),
})
).toEqual([
{
name: INNGEST_SURVEY_START_EVENT,
data: {
surveyId: "survey_1",
environmentId: "env_1",
scheduledFor: startsAt.toISOString(),
},
ts: startsAt.getTime(),
},
]);
});
test("builds an end event when endsAt is set on create", () => {
const endsAt = new Date("2026-04-02T12:00:00.000Z");
expect(
getSurveyLifecycleEvents({
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt: null,
endsAt,
},
now: new Date("2026-03-31T12:00:00.000Z"),
})
).toEqual([
{
name: INNGEST_SURVEY_END_EVENT,
data: {
surveyId: "survey_1",
environmentId: "env_1",
scheduledFor: endsAt.toISOString(),
},
ts: endsAt.getTime(),
},
]);
});
test("builds both lifecycle events when both dates are set on create", () => {
const startsAt = new Date("2026-04-01T12:00:00.000Z");
const endsAt = new Date("2026-04-02T12:00:00.000Z");
const events = getSurveyLifecycleEvents({
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt,
endsAt,
},
now: new Date("2026-03-31T12:00:00.000Z"),
});
expect(events).toHaveLength(2);
expect(events[0]?.name).toBe(INNGEST_SURVEY_START_EVENT);
expect(events[1]?.name).toBe(INNGEST_SURVEY_END_EVENT);
});
test("does nothing when neither lifecycle date is set", () => {
expect(
getSurveyLifecycleEvents({
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt: null,
endsAt: null,
},
})
).toEqual([]);
});
test("builds a lifecycle event when a date transitions from null to a value", () => {
const startsAt = new Date("2026-04-01T12:00:00.000Z");
expect(
getSurveyLifecycleEvents({
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt,
endsAt: null,
},
previousSurvey: {
startsAt: null,
endsAt: null,
},
now: new Date("2026-03-31T12:00:00.000Z"),
})
).toHaveLength(1);
});
test("does not build events when a lifecycle date changes after already being set", () => {
expect(
getSurveyLifecycleEvents({
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt: new Date("2026-04-02T12:00:00.000Z"),
endsAt: null,
},
previousSurvey: {
startsAt: new Date("2026-04-01T12:00:00.000Z"),
endsAt: null,
},
})
).toEqual([]);
});
test("does not build events when a lifecycle date is cleared", () => {
expect(
getSurveyLifecycleEvents({
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt: null,
endsAt: null,
},
previousSurvey: {
startsAt: new Date("2026-04-01T12:00:00.000Z"),
endsAt: null,
},
})
).toEqual([]);
});
test("publishes immediate events without a scheduled timestamp when the date is in the past", async () => {
const sender = vi.fn().mockResolvedValue(undefined);
const startsAt = new Date("2026-03-30T12:00:00.000Z");
await publishSurveyLifecycleEvents({
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt,
endsAt: null,
},
now: new Date("2026-03-31T12:00:00.000Z"),
sender,
});
expect(sender).toHaveBeenCalledWith([
{
name: INNGEST_SURVEY_START_EVENT,
data: {
surveyId: "survey_1",
environmentId: "env_1",
scheduledFor: startsAt.toISOString(),
},
},
]);
});
test("builds lifecycle cancellation events for survey deletion", () => {
expect(
getSurveyLifecycleCancellationEvents({
surveyId: "survey_1",
environmentId: "env_1",
})
).toEqual([
{
name: INNGEST_SURVEY_START_CANCELLED_EVENT,
data: {
surveyId: "survey_1",
environmentId: "env_1",
},
},
{
name: INNGEST_SURVEY_END_CANCELLED_EVENT,
data: {
surveyId: "survey_1",
environmentId: "env_1",
},
},
]);
});
test("logs and rethrows publish failures", async () => {
const sender = vi.fn().mockRejectedValue(new Error("send failed"));
await expect(
publishSurveyLifecycleEvents({
survey: {
id: "survey_1",
environmentId: "env_1",
startsAt: new Date("2026-04-01T12:00:00.000Z"),
endsAt: null,
},
sender,
})
).rejects.toThrow("send failed");
expect(logger.error).toHaveBeenCalledWith(
{
error: expect.any(Error),
surveyId: "survey_1",
},
"Failed to publish survey lifecycle events"
);
});
test("logs and rethrows cancellation publish failures", async () => {
const sender = vi.fn().mockRejectedValue(new Error("cancel failed"));
await expect(
publishSurveyLifecycleCancellationEvents({
surveyId: "survey_1",
environmentId: "env_1",
sender,
})
).rejects.toThrow("cancel failed");
expect(logger.error).toHaveBeenCalledWith(
{
error: expect.any(Error),
surveyId: "survey_1",
},
"Failed to publish survey lifecycle cancellation events"
);
});
});
+119
View File
@@ -0,0 +1,119 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
import { type InngestSendableEvent, sendInngestEvents } from "./client";
import {
INNGEST_SURVEY_END_CANCELLED_EVENT,
INNGEST_SURVEY_END_EVENT,
INNGEST_SURVEY_START_CANCELLED_EVENT,
INNGEST_SURVEY_START_EVENT,
} from "./constants";
interface SurveyLifecycleSurvey {
id: TSurvey["id"];
environmentId: TSurvey["environmentId"];
startsAt?: TSurvey["startsAt"];
endsAt?: TSurvey["endsAt"];
}
interface PublishSurveyLifecycleEventsOptions {
survey: SurveyLifecycleSurvey;
previousSurvey?: Pick<SurveyLifecycleSurvey, "startsAt" | "endsAt"> | null;
now?: Date;
sender?: (events: InngestSendableEvent[]) => Promise<unknown>;
}
interface PublishSurveyLifecycleCancellationEventsOptions {
surveyId: string;
environmentId: string;
sender?: (events: InngestSendableEvent[]) => Promise<unknown>;
}
const shouldPublishTransition = (previousValue?: Date | null, nextValue?: Date | null): nextValue is Date =>
previousValue == null && nextValue != null;
const buildScheduledEvent = (
name: string,
survey: SurveyLifecycleSurvey,
scheduledFor: Date,
now: Date
): InngestSendableEvent => ({
name,
data: {
surveyId: survey.id,
environmentId: survey.environmentId,
scheduledFor: scheduledFor.toISOString(),
},
...(scheduledFor.getTime() > now.getTime() ? { ts: scheduledFor.getTime() } : {}),
});
export const getSurveyLifecycleEvents = ({
survey,
previousSurvey,
now = new Date(),
}: Omit<PublishSurveyLifecycleEventsOptions, "sender">): InngestSendableEvent[] => {
const events: InngestSendableEvent[] = [];
if (shouldPublishTransition(previousSurvey?.startsAt ?? null, survey.startsAt ?? null)) {
events.push(buildScheduledEvent(INNGEST_SURVEY_START_EVENT, survey, survey.startsAt, now));
}
if (shouldPublishTransition(previousSurvey?.endsAt ?? null, survey.endsAt ?? null)) {
events.push(buildScheduledEvent(INNGEST_SURVEY_END_EVENT, survey, survey.endsAt, now));
}
return events;
};
export const publishSurveyLifecycleEvents = async ({
survey,
previousSurvey,
now = new Date(),
sender = sendInngestEvents,
}: PublishSurveyLifecycleEventsOptions): Promise<void> => {
const events = getSurveyLifecycleEvents({ survey, previousSurvey, now });
if (events.length === 0) {
return;
}
try {
await sender(events);
} catch (error) {
logger.error({ error, surveyId: survey.id }, "Failed to publish survey lifecycle events");
throw error;
}
};
export const getSurveyLifecycleCancellationEvents = ({
surveyId,
environmentId,
}: Omit<PublishSurveyLifecycleCancellationEventsOptions, "sender">): InngestSendableEvent[] => [
{
name: INNGEST_SURVEY_START_CANCELLED_EVENT,
data: {
surveyId,
environmentId,
},
},
{
name: INNGEST_SURVEY_END_CANCELLED_EVENT,
data: {
surveyId,
environmentId,
},
},
];
export const publishSurveyLifecycleCancellationEvents = async ({
surveyId,
environmentId,
sender = sendInngestEvents,
}: PublishSurveyLifecycleCancellationEventsOptions): Promise<void> => {
try {
await sender(getSurveyLifecycleCancellationEvents({ surveyId, environmentId }));
} catch (error) {
logger.error({ error, surveyId }, "Failed to publish survey lifecycle cancellation events");
throw error;
}
};
+2 -2
View File
@@ -1,7 +1,7 @@
"use server";
import "server-only";
import { AuthorizationError } from "@formbricks/types/errors";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "../../organization/service";
import { getMembershipByUserIdOrganizationId } from "../service";
@@ -9,7 +9,7 @@ export const getMembershipByUserIdOrganizationIdAction = async (environmentId: s
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error("Organization not found");
throw new ResourceNotFoundError("Organization", null);
}
const currentUserMembership = await getMembershipRole(userId, organization.id);
+1 -1
View File
@@ -378,7 +378,7 @@ export const getResponseDownloadFile = async (
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
if (!organizationId) {
throw new Error("Organization ID not found");
throw new ResourceNotFoundError("Organization", null);
}
const organizationBilling = await getOrganizationBilling(organizationId);
@@ -193,6 +193,8 @@ const mockWelcomeCard: TSurveyWelcomeCard = {
const baseSurveyProperties = {
id: mockId,
name: "Mock Survey",
startsAt: null,
endsAt: null,
autoClose: 10,
delay: 0,
autoComplete: 7,
+67 -1
View File
@@ -4,11 +4,18 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { testInputValidation } from "vitestSetup";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { logger } from "@formbricks/logger";
import { TActionClass } from "@formbricks/types/action-classes";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyCreateInput, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getActionClasses } from "@/lib/actionClass/service";
import { publishSurveyLifecycleEvents } from "@/lib/inngest/survey-lifecycle";
import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
@@ -49,8 +56,23 @@ vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(),
}));
vi.mock("@/lib/inngest/survey-lifecycle", () => ({
publishSurveyLifecycleEvents: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
beforeEach(() => {
prisma.survey.count.mockResolvedValue(1);
prisma.$transaction.mockImplementation(async (callback: (tx: typeof prisma) => Promise<unknown>) =>
callback(prisma)
);
vi.mocked(publishSurveyLifecycleEvents).mockReset();
vi.mocked(logger.error).mockReset();
});
describe("evaluateLogic with mockSurveyWithLogic", () => {
@@ -307,6 +329,18 @@ describe("Tests for updateSurvey", () => {
prisma.survey.update.mockResolvedValueOnce(mockSurveyOutput);
const updatedSurvey = await updateSurvey(updateSurveyInput);
expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
expect(publishSurveyLifecycleEvents).toHaveBeenCalledWith({
survey: {
id: mockTransformedSurveyOutput.id,
environmentId: mockTransformedSurveyOutput.environmentId,
startsAt: mockTransformedSurveyOutput.startsAt,
endsAt: mockTransformedSurveyOutput.endsAt,
},
previousSurvey: {
startsAt: mockTransformedSurveyOutput.startsAt,
endsAt: mockTransformedSurveyOutput.endsAt,
},
});
});
// Note: Language handling tests (for languages.length > 0 fix) are covered in
@@ -341,6 +375,26 @@ describe("Tests for updateSurvey", () => {
prisma.survey.update.mockRejectedValue(new Error(mockErrorMessage));
await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(Error);
});
test("surfaces post-commit Inngest publish failures", async () => {
prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
prisma.survey.update.mockResolvedValueOnce(mockSurveyOutput);
vi.mocked(publishSurveyLifecycleEvents).mockRejectedValueOnce(new Error("send failed"));
await expect(updateSurvey(updateSurveyInput)).rejects.toThrow("send failed");
expect(prisma.survey.update).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Error updating survey");
});
test("throws a validation error when startsAt is not before endsAt", async () => {
await expect(
updateSurvey({
...updateSurveyInput,
startsAt: new Date("2026-04-02T12:00:00.000Z"),
endsAt: new Date("2026-04-01T12:00:00.000Z"),
})
).rejects.toThrow(ValidationError);
});
});
});
@@ -644,6 +698,14 @@ describe("Tests for createSurvey", () => {
expect(prisma.survey.create).toHaveBeenCalled();
expect(result.name).toEqual(mockSurveyOutput.name);
expect(publishSurveyLifecycleEvents).toHaveBeenCalledWith({
survey: {
id: mockTransformedSurveyOutput.id,
environmentId: mockTransformedSurveyOutput.environmentId,
startsAt: mockTransformedSurveyOutput.startsAt,
endsAt: mockTransformedSurveyOutput.endsAt,
},
});
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
});
@@ -663,6 +725,10 @@ describe("Tests for createSurvey", () => {
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as TSegment);
prisma.survey.update.mockResolvedValueOnce({
...mockSurveyOutput,
type: "app",
});
await createSurvey(mockEnvironmentId, {
...mockCreateSurveyInput,
+192 -209
View File
@@ -7,6 +7,7 @@ import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import { publishSurveyLifecycleEvents } from "@/lib/inngest/survey-lifecycle";
import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
@@ -32,6 +33,8 @@ export const selectSurvey = {
environmentId: true,
createdBy: true,
status: true,
startsAt: true,
endsAt: true,
welcomeCard: true,
questions: true,
blocks: true,
@@ -300,8 +303,6 @@ export const updateSurveyInternal = async (
try {
const surveyId = updatedSurvey.id;
let data: any = {};
const actionClasses = await getActionClasses(updatedSurvey.environmentId);
const currentSurvey = await getSurvey(surveyId);
@@ -324,100 +325,95 @@ export const updateSurveyInternal = async (
}
}
if (languages) {
// Process languages update logic here
// Extract currentLanguageIds and updatedLanguageIds
const currentLanguageIds = currentSurvey.languages
? currentSurvey.languages.map((l) => l.language.id)
: [];
const updatedLanguageIds =
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
const enabledLanguageIds = languages.map((language) => {
if (language.enabled) return language.language.id;
});
// Determine languages to add and remove
const languagesToAdd = updatedLanguageIds.filter((id) => !currentLanguageIds.includes(id));
const languagesToRemove = currentLanguageIds.filter((id) => !updatedLanguageIds.includes(id));
const defaultLanguageId = updatedSurvey.languages.find((l) => l.default)?.language.id;
// Prepare data for Prisma update
data.languages = {};
// Update existing languages for default value changes
data.languages.updateMany = currentSurvey.languages.map((surveyLanguage) => ({
where: { languageId: surveyLanguage.language.id },
data: {
default: surveyLanguage.language.id === defaultLanguageId,
enabled: enabledLanguageIds.includes(surveyLanguage.language.id),
},
}));
// Add new languages
if (languagesToAdd.length > 0) {
data.languages.create = languagesToAdd.map((languageId) => ({
languageId: languageId,
default: languageId === defaultLanguageId,
enabled: enabledLanguageIds.includes(languageId),
}));
}
// Remove languages no longer associated with the survey
if (languagesToRemove.length > 0) {
data.languages.deleteMany = languagesToRemove.map((languageId) => ({
languageId: languageId,
enabled: enabledLanguageIds.includes(languageId),
}));
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
if (triggers) {
data.triggers = handleTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
}
const prismaSurvey = await prisma.$transaction(async (tx) => {
let data: Prisma.SurveyUpdateInput = {};
// if the survey body has type other than "app" but has a private segment, we delete that segment, and if it has a public segment, we disconnect from to the survey
if (segment) {
if (type === "app") {
// parse the segment filters:
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!skipValidation && !parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
if (languages) {
const currentLanguageIds = currentSurvey.languages
? currentSurvey.languages.map((language) => language.language.id)
: [];
const updatedLanguageIds =
languages.length > 0 ? updatedSurvey.languages.map((language) => language.language.id) : [];
const enabledLanguageIds = languages.flatMap((language) =>
language.enabled ? [language.language.id] : []
);
try {
// update the segment:
let updatedInput: Prisma.SegmentUpdateInput = {
...segment,
surveys: undefined,
};
const languagesToAdd = updatedLanguageIds.filter((id) => !currentLanguageIds.includes(id));
const languagesToRemove = currentLanguageIds.filter((id) => !updatedLanguageIds.includes(id));
const defaultLanguageId = updatedSurvey.languages.find((language) => language.default)?.language.id;
if (segment.surveys) {
updatedInput = {
...segment,
surveys: {
connect: segment.surveys.map((surveyId) => ({ id: surveyId })),
},
};
data.languages = {
updateMany: currentSurvey.languages.map((surveyLanguage) => ({
where: { languageId: surveyLanguage.language.id },
data: {
default: surveyLanguage.language.id === defaultLanguageId,
enabled: enabledLanguageIds.includes(surveyLanguage.language.id),
},
})),
create:
languagesToAdd.length > 0
? languagesToAdd.map((languageId) => ({
languageId,
default: languageId === defaultLanguageId,
enabled: enabledLanguageIds.includes(languageId),
}))
: undefined,
deleteMany:
languagesToRemove.length > 0
? languagesToRemove.map((languageId) => ({
languageId,
enabled: enabledLanguageIds.includes(languageId),
}))
: undefined,
};
}
if (triggers) {
data.triggers = handleTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
}
if (segment) {
if (type === "app") {
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!skipValidation && !parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
await prisma.segment.update({
where: { id: segment.id },
data: updatedInput,
select: {
surveys: { select: { id: true } },
environmentId: true,
id: true,
},
});
} catch (error) {
logger.error(error, "Error updating survey");
throw new Error("Error updating survey");
}
} else {
if (segment.isPrivate) {
// disconnect the private segment first and then delete:
await prisma.segment.update({
try {
let updatedInput: Prisma.SegmentUpdateInput = {
...segment,
surveys: undefined,
};
if (segment.surveys) {
updatedInput = {
...segment,
surveys: {
connect: segment.surveys.map((segmentSurveyId) => ({ id: segmentSurveyId })),
},
};
}
await tx.segment.update({
where: { id: segment.id },
data: updatedInput,
select: {
surveys: { select: { id: true } },
environmentId: true,
id: true,
},
});
} catch (error) {
logger.error(error, "Error updating survey");
throw new Error("Error updating survey");
}
} else if (segment.isPrivate) {
await tx.segment.update({
where: { id: segment.id },
data: {
surveys: {
@@ -428,14 +424,13 @@ export const updateSurveyInternal = async (
},
});
// delete the private segment:
await prisma.segment.delete({
await tx.segment.delete({
where: {
id: segment.id,
},
});
} else {
await prisma.survey.update({
await tx.survey.update({
where: {
id: surveyId,
},
@@ -446,10 +441,8 @@ export const updateSurveyInternal = async (
},
});
}
}
} else if (type === "app") {
if (!currentSurvey.segment) {
await prisma.survey.update({
} else if (type === "app" && !currentSurvey.segment) {
await tx.survey.update({
where: {
id: surveyId,
},
@@ -477,102 +470,89 @@ export const updateSurveyInternal = async (
},
});
}
}
if (followUps) {
// Separate follow-ups into categories based on deletion flag
const deletedFollowUps = followUps.filter((followUp) => followUp.deleted);
const nonDeletedFollowUps = followUps.filter((followUp) => !followUp.deleted);
if (followUps) {
const deletedFollowUps = followUps.filter((followUp) => followUp.deleted);
const nonDeletedFollowUps = followUps.filter((followUp) => !followUp.deleted);
const existingFollowUpIds = new Set(currentSurvey.followUps.map((followUp) => followUp.id));
// Get set of existing follow-up IDs from currentSurvey
const existingFollowUpIds = new Set(currentSurvey.followUps.map((f) => f.id));
const existingFollowUps = nonDeletedFollowUps.filter((followUp) =>
existingFollowUpIds.has(followUp.id)
);
const newFollowUps = nonDeletedFollowUps.filter((followUp) => !existingFollowUpIds.has(followUp.id));
// Separate non-deleted follow-ups into new and existing
const existingFollowUps = nonDeletedFollowUps.filter((followUp) =>
existingFollowUpIds.has(followUp.id)
);
const newFollowUps = nonDeletedFollowUps.filter((followUp) => !existingFollowUpIds.has(followUp.id));
data.followUps = {
// Update existing follow-ups
updateMany: existingFollowUps.map((followUp) => ({
where: {
id: followUp.id,
},
data: {
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
},
})),
// Create new follow-ups
createMany:
newFollowUps.length > 0
? {
data: newFollowUps.map((followUp) => ({
data.followUps = {
updateMany: existingFollowUps.map((followUp) => ({
where: {
id: followUp.id,
},
data: {
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
},
})),
createMany:
newFollowUps.length > 0
? {
data: newFollowUps.map((followUp) => ({
id: followUp.id,
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
}
: undefined,
deleteMany:
deletedFollowUps.length > 0
? deletedFollowUps.map((followUp) => ({
id: followUp.id,
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
}
: undefined,
// Delete follow-ups marked as deleted, regardless of whether they exist in DB
deleteMany:
deletedFollowUps.length > 0
? deletedFollowUps.map((followUp) => ({
id: followUp.id,
}))
: undefined,
};
}
}))
: undefined,
};
}
data.questions = questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
data.questions = questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
});
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
data.blocks = stripIsDraftFromBlocks(updatedSurvey.blocks);
}
surveyData.updatedAt = new Date();
data = {
...surveyData,
...data,
type,
};
delete data.createdBy;
return tx.survey.update({
where: { id: surveyId },
data,
select: selectSurvey,
});
});
// Strip isDraft from elements before saving
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
data.blocks = stripIsDraftFromBlocks(updatedSurvey.blocks);
}
const transformedSurvey = transformPrismaSurvey<TSurvey>(prismaSurvey);
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
surveyData.updatedAt = new Date();
data = {
...surveyData,
...data,
type,
};
delete data.createdBy;
const prismaSurvey = await prisma.survey.update({
where: { id: surveyId },
data,
select: selectSurvey,
await publishSurveyLifecycleEvents({
survey: {
id: transformedSurvey.id,
environmentId: transformedSurvey.environmentId,
startsAt: transformedSurvey.startsAt,
endsAt: transformedSurvey.endsAt,
},
previousSurvey: {
startsAt: currentSurvey.startsAt ?? null,
endsAt: currentSurvey.endsAt ?? null,
},
});
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
return transformedSurvey;
} catch (error) {
logger.error(error, "Error updating survey");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -651,23 +631,26 @@ export const createSurvey = async (
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
}
const survey = await prisma.survey.create({
data: {
...data,
environment: {
connect: {
id: parsedEnvironmentId,
const survey = await prisma.$transaction(async (tx) => {
const createdSurvey = await tx.survey.create({
data: {
...data,
environment: {
connect: {
id: parsedEnvironmentId,
},
},
},
},
select: selectSurvey,
});
select: selectSurvey,
});
// if the survey created is an "app" survey, we also create a private segment for it.
if (survey.type === "app") {
const newSegment = await prisma.segment.create({
if (createdSurvey.type !== "app") {
return createdSurvey;
}
const newSegment = await tx.segment.create({
data: {
title: survey.id,
title: createdSurvey.id,
filters: [],
isPrivate: true,
environment: {
@@ -678,9 +661,9 @@ export const createSurvey = async (
},
});
await prisma.survey.update({
return tx.survey.update({
where: {
id: survey.id,
id: createdSurvey.id,
},
data: {
segment: {
@@ -689,20 +672,20 @@ export const createSurvey = async (
},
},
},
select: selectSurvey,
});
}
});
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
// @ts-expect-error
const transformedSurvey: TSurvey = {
...survey,
...(survey.segment && {
segment: {
...survey.segment,
surveys: survey.segment.surveys.map((survey) => survey.id),
},
}),
};
const transformedSurvey = transformPrismaSurvey<TSurvey>(survey);
await publishSurveyLifecycleEvents({
survey: {
id: transformedSurvey.id,
environmentId: transformedSurvey.environmentId,
startsAt: transformedSurvey.startsAt,
endsAt: transformedSurvey.endsAt,
},
});
if (createdBy) {
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "Verbinden",
"connect_formbricks": "Formbricks verbinden",
"connected": "Verbunden",
"contact": "Kontakt",
"contacts": "Kontakte",
"continue": "Weitermachen",
"copied": "Kopiert",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(Kopie {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"elements": "Elemente",
"email": "E-Mail",
"ending_card": "Abschluss-Karte",
"enter_url": "URL eingeben",
"enterprise_license": "Enterprise Lizenz",
"environment": "Umgebung",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
"error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
@@ -283,7 +284,9 @@
"marketing": "Marketing",
"members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams",
"membership": "Mitgliedschaft",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"meta": "Meta",
"metadata": "Metadaten",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
@@ -323,10 +326,9 @@
"or": "oder",
"organization": "Organisation",
"organization_id": "Organisations-ID",
"organization_not_found": "Organisation nicht gefunden",
"organization_settings": "Organisationseinstellungen",
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
"other": "Andere",
"other_filters": "Weitere Filter",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
@@ -418,7 +420,6 @@
"survey_id": "Umfrage-ID",
"survey_languages": "Umfragesprachen",
"survey_live": "Umfrage live",
"survey_not_found": "Umfrage nicht gefunden",
"survey_paused": "Umfrage pausiert.",
"survey_type": "Umfragetyp",
"surveys": "Umfragen",
@@ -433,7 +434,6 @@
"team_name": "Teamname",
"team_role": "Team-Rolle",
"teams": "Teams",
"teams_not_found": "Teams nicht gefunden",
"text": "Text",
"time": "Zeit",
"time_to_finish": "Zeit zum Fertigstellen",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "Benutzer",
"user_id": "Benutzer-ID",
"user_not_found": "Benutzer nicht gefunden",
"variable": "Variable",
"variable_ids": "Variablen-IDs",
"variables": "Variablen",
@@ -473,14 +472,13 @@
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workflows": "Workflows",
"workspace": "Arbeitsbereich",
"workspace_configuration": "Projektkonfiguration",
"workspace_created_successfully": "Projekt erfolgreich erstellt",
"workspace_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"workspace_id": "Projekt-ID",
"workspace_name": "Projektname",
"workspace_name_placeholder": "z. B. Formbricks",
"workspace_not_found": "Projekt nicht gefunden",
"workspace_permission_not_found": "Projektberechtigung nicht gefunden",
"workspaces": "Projekte",
"years": "Jahre",
"you": "Du",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"contact_not_found": "Kein solcher Kontakt gefunden",
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"create_attribute": "Attribut erstellen",
+9 -12
View File
@@ -167,6 +167,7 @@
"connect": "Connect",
"connect_formbricks": "Connect Formbricks",
"connected": "Connected",
"contact": "Contact",
"contacts": "Contacts",
"continue": "Continue",
"copied": "Copied",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(copy {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Edit",
"elements": "Elements",
"email": "Email",
"ending_card": "Ending card",
"enter_url": "Enter URL",
"enterprise_license": "Enterprise License",
"environment": "Environment",
"environment_not_found": "Environment not found",
"environment_notice": "You are currently in the {environment} environment.",
"error": "Error",
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
@@ -261,11 +262,11 @@
"invalid_file_type": "Invalid file type",
"invite": "Invite",
"invite_them": "Invite them",
"javascript_required": "JavaScript Required",
"javascript_required_description": "Formbricks requires JavaScript to function properly. Please enable JavaScript in your browser settings to continue.",
"key": "Key",
"label": "Label",
"language": "Language",
"javascript_required": "JavaScript Required",
"javascript_required_description": "Formbricks requires JavaScript to function properly. Please enable JavaScript in your browser settings to continue.",
"last_name": "Last Name",
"learn_more": "Learn more",
"license_expired": "License Expired",
@@ -283,7 +284,9 @@
"marketing": "Marketing",
"members": "Members",
"members_and_teams": "Members & Teams",
"membership": "Membership",
"membership_not_found": "Membership not found",
"meta": "Meta",
"metadata": "Metadata",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Do not worry your surveys look great on every device and screen size!",
@@ -323,10 +326,9 @@
"or": "or",
"organization": "Organization",
"organization_id": "Organization ID",
"organization_not_found": "Organization not found",
"organization_settings": "Organization settings",
"organization_teams_not_found": "Organization teams not found",
"other": "Other",
"other_filters": "Other Filters",
"others": "Others",
"overlay_color": "Overlay color",
"overview": "Overview",
@@ -418,7 +420,6 @@
"survey_id": "Survey ID",
"survey_languages": "Survey Languages",
"survey_live": "Survey live",
"survey_not_found": "Survey not found",
"survey_paused": "Survey paused.",
"survey_type": "Survey Type",
"surveys": "Surveys",
@@ -433,7 +434,6 @@
"team_name": "Team name",
"team_role": "Team role",
"teams": "Teams",
"teams_not_found": "Teams not found",
"text": "Text",
"time": "Time",
"time_to_finish": "Time to finish",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "User",
"user_id": "User ID",
"user_not_found": "User not found",
"variable": "Variable",
"variable_ids": "Variable IDs",
"variables": "Variables",
@@ -473,14 +472,13 @@
"weeks": "weeks",
"welcome_card": "Welcome card",
"workflows": "Workflows",
"workspace": "Workspace",
"workspace_configuration": "Workspace Configuration",
"workspace_created_successfully": "Workspace created successfully",
"workspace_creation_description": "Organize surveys in workspaces for better access control.",
"workspace_id": "Workspace ID",
"workspace_name": "Workspace Name",
"workspace_name_placeholder": "e.g. Formbricks",
"workspace_not_found": "Workspace not found",
"workspace_permission_not_found": "Workspace permission not found",
"workspaces": "Workspaces",
"years": "years",
"you": "You",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
"contact_deleted_successfully": "Contact deleted successfully",
"contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"create_attribute": "Create attribute",
@@ -3354,4 +3351,4 @@
"thank_you_description": "Your input helps us build the Workflows feature you actually need. We will keep you posted on our progress.",
"thank_you_title": "Thank you for your feedback!"
}
}
}
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
"connected": "Conectado",
"contact": "Contacto",
"contacts": "Contactos",
"continue": "Continuar",
"copied": "Copiado",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(copia {copyNumber})",
"e_commerce": "Comercio electrónico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Tarjeta final",
"enter_url": "Introducir URL",
"enterprise_license": "Licencia empresarial",
"environment": "Entorno",
"environment_not_found": "Entorno no encontrado",
"environment_notice": "Actualmente estás en el entorno {environment}.",
"error": "Error",
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
@@ -283,7 +284,9 @@
"marketing": "Marketing",
"members": "Miembros",
"members_and_teams": "Miembros y equipos",
"membership": "Membresía",
"membership_not_found": "Membresía no encontrada",
"meta": "Meta",
"metadata": "Metadatos",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
"mobile_overlay_surveys_look_good": "No te preocupes ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
@@ -323,10 +326,9 @@
"or": "o",
"organization": "Organización",
"organization_id": "ID de organización",
"organization_not_found": "Organización no encontrada",
"organization_settings": "Ajustes de la organización",
"organization_teams_not_found": "Equipos de la organización no encontrados",
"other": "Otro",
"other_filters": "Otros Filtros",
"others": "Otros",
"overlay_color": "Color de superposición",
"overview": "Resumen",
@@ -418,7 +420,6 @@
"survey_id": "ID de encuesta",
"survey_languages": "Idiomas de la encuesta",
"survey_live": "Encuesta activa",
"survey_not_found": "Encuesta no encontrada",
"survey_paused": "Encuesta pausada.",
"survey_type": "Tipo de encuesta",
"surveys": "Encuestas",
@@ -433,7 +434,6 @@
"team_name": "Nombre del equipo",
"team_role": "Rol del equipo",
"teams": "Equipos",
"teams_not_found": "Equipos no encontrados",
"text": "Texto",
"time": "Hora",
"time_to_finish": "Tiempo para finalizar",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "Usuario",
"user_id": "ID de usuario",
"user_not_found": "Usuario no encontrado",
"variable": "Variable",
"variable_ids": "IDs de variables",
"variables": "Variables",
@@ -473,14 +472,13 @@
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workflows": "Flujos de trabajo",
"workspace": "Espacio de trabajo",
"workspace_configuration": "Configuración del proyecto",
"workspace_created_successfully": "Proyecto creado correctamente",
"workspace_creation_description": "Organiza las encuestas en proyectos para un mejor control de acceso.",
"workspace_id": "ID del proyecto",
"workspace_name": "Nombre del proyecto",
"workspace_name_placeholder": "p. ej. Formbricks",
"workspace_not_found": "Proyecto no encontrado",
"workspace_permission_not_found": "Permiso del proyecto no encontrado",
"workspaces": "Proyectos",
"years": "años",
"you": "Tú",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.",
"contact_deleted_successfully": "Contacto eliminado correctamente",
"contact_not_found": "No se ha encontrado dicho contacto",
"contacts_table_refresh": "Actualizar contactos",
"contacts_table_refresh_success": "Contactos actualizados correctamente",
"create_attribute": "Crear atributo",
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "Connecter",
"connect_formbricks": "Connecter Formbricks",
"connected": "Connecté",
"contact": "Contact",
"contacts": "Contacts",
"continue": "Continuer",
"copied": "Copié",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Modifier",
"elements": "Éléments",
"email": "Email",
"ending_card": "Carte de fin",
"enter_url": "Saisir l'URL",
"enterprise_license": "Licence d'entreprise",
"environment": "Environnement",
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
"error": "Erreur",
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
@@ -283,7 +284,9 @@
"marketing": "Marketing",
"members": "Membres",
"members_and_teams": "Membres & Équipes",
"membership": "Adhésion",
"membership_not_found": "Abonnement non trouvé",
"meta": "Méta",
"metadata": "Métadonnées",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
@@ -323,10 +326,9 @@
"or": "ou",
"organization": "Organisation",
"organization_id": "Identifiant de l'organisation",
"organization_not_found": "Organisation non trouvée",
"organization_settings": "Paramètres de l'organisation",
"organization_teams_not_found": "Équipes d'organisation non trouvées",
"other": "Autre",
"other_filters": "Autres filtres",
"others": "Autres",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
@@ -418,7 +420,6 @@
"survey_id": "ID de l'enquête",
"survey_languages": "Langues de l'enquête",
"survey_live": "Sondage en direct",
"survey_not_found": "Sondage non trouvé",
"survey_paused": "Sondage en pause.",
"survey_type": "Type de sondage",
"surveys": "Enquêtes",
@@ -433,7 +434,6 @@
"team_name": "Nom de l'équipe",
"team_role": "Rôle dans l'équipe",
"teams": "Équipes",
"teams_not_found": "Équipes non trouvées",
"text": "Texte",
"time": "Temps",
"time_to_finish": "Temps de finir",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "Utilisateur",
"user_id": "Identifiant d'utilisateur",
"user_not_found": "Utilisateur non trouvé",
"variable": "Variable",
"variable_ids": "Identifiants variables",
"variables": "Variables",
@@ -473,14 +472,13 @@
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workflows": "Workflows",
"workspace": "Espace de travail",
"workspace_configuration": "Configuration du projet",
"workspace_created_successfully": "Projet créé avec succès",
"workspace_creation_description": "Organisez les enquêtes dans des projets pour un meilleur contrôle d'accès.",
"workspace_id": "ID du projet",
"workspace_name": "Nom du projet",
"workspace_name_placeholder": "par ex. Formbricks",
"workspace_not_found": "Projet introuvable",
"workspace_permission_not_found": "Permission du projet introuvable",
"workspaces": "Projets",
"years": "années",
"you": "Vous",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "Nouvel attribut “{key}” créé avec le type “{dataType}”",
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
"contact_deleted_successfully": "Contact supprimé avec succès",
"contact_not_found": "Aucun contact trouvé",
"contacts_table_refresh": "Actualiser les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"create_attribute": "Créer un attribut",
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "Kapcsolódás",
"connect_formbricks": "Kapcsolódás a Formbrickshez",
"connected": "Kapcsolódva",
"contact": "Kapcsolat",
"contacts": "Partnerek",
"continue": "Folytatás",
"copied": "Másolva",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "({copyNumber}. másolat)",
"e_commerce": "E-kereskedelem",
"edit": "Szerkesztés",
"elements": "Elemek",
"email": "E-mail",
"ending_card": "Befejező kártya",
"enter_url": "URL megadása",
"enterprise_license": "Vállalati licenc",
"environment": "Környezet",
"environment_not_found": "A környezet nem található",
"environment_notice": "Ön jelenleg a(z) {environment} környezetben van.",
"error": "Hiba",
"error_component_description": "Ez az erőforrás nem létezik, vagy nem rendelkezik a hozzáféréshez szükséges jogosultságokkal.",
@@ -283,7 +284,9 @@
"marketing": "Marketing",
"members": "Tagok",
"members_and_teams": "Tagok és csapatok",
"membership": "Tagság",
"membership_not_found": "A tagság nem található",
"meta": "Meta",
"metadata": "Metaadatok",
"mobile_overlay_app_works_best_on_desktop": "A Formbricks nagyobb képernyőn működik a legjobban. A kérdőívek kezeléséhez vagy összeállításához váltson másik eszközre.",
"mobile_overlay_surveys_look_good": "Ne aggódjon a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
@@ -323,10 +326,9 @@
"or": "vagy",
"organization": "Szervezet",
"organization_id": "Szervezetazonosító",
"organization_not_found": "A szervezet nem található",
"organization_settings": "Szervezet beállításai",
"organization_teams_not_found": "A szervezeti csapatok nem találhatók",
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"others": "Mások",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
@@ -418,7 +420,6 @@
"survey_id": "Kérdőív-azonosító",
"survey_languages": "Kérdőív nyelvei",
"survey_live": "A kérdőív élő",
"survey_not_found": "A kérdőív nem található",
"survey_paused": "A kérdőív szüneteltetve.",
"survey_type": "Kérdőív típusa",
"surveys": "Kérdőívek",
@@ -433,7 +434,6 @@
"team_name": "Csapat neve",
"team_role": "Csapatszerep",
"teams": "Csapatok",
"teams_not_found": "A csapatok nem találhatók",
"text": "Szöveg",
"time": "Idő",
"time_to_finish": "Idő a befejezésig",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "Felhasználó",
"user_id": "Felhasználó-azonosító",
"user_not_found": "A felhasználó nem található",
"variable": "Változó",
"variable_ids": "Változóazonosítók",
"variables": "Változók",
@@ -473,14 +472,13 @@
"weeks": "hét",
"welcome_card": "Üdvözlő kártya",
"workflows": "Munkafolyamatok",
"workspace": "Munkaterület",
"workspace_configuration": "Munkaterület beállítása",
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
"workspace_creation_description": "Kérdőívek munkaterületekre szervezése a jobb hozzáférés-vezérlés érdekében.",
"workspace_id": "Munkaterület-azonosító",
"workspace_name": "Munkaterület neve",
"workspace_name_placeholder": "például Formbricks",
"workspace_not_found": "A munkaterület nem található",
"workspace_permission_not_found": "A munkaterület-jogosultság nem található",
"workspaces": "Munkaterületek",
"years": "év",
"you": "Ön",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key}” attribútum létrehozva",
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, és nem lett frissítve.",
"contact_deleted_successfully": "A partner sikeresen törölve",
"contact_not_found": "Nem található ilyen partner",
"contacts_table_refresh": "Partnerek frissítése",
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
"create_attribute": "Attribútum létrehozása",
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "接続",
"connect_formbricks": "Formbricksを接続",
"connected": "接続済み",
"contact": "連絡先",
"contacts": "連絡先",
"continue": "続行",
"copied": "コピーしました",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(コピー {copyNumber})",
"e_commerce": "Eコマース",
"edit": "編集",
"elements": "要素",
"email": "メールアドレス",
"ending_card": "終了カード",
"enter_url": "URLを入力",
"enterprise_license": "エンタープライズライセンス",
"environment": "環境",
"environment_not_found": "環境が見つかりません",
"environment_notice": "現在、{environment} 環境にいます。",
"error": "エラー",
"error_component_description": "この リソース は 存在 しない か、アクセス する ための 必要な 権限 が ありません。",
@@ -283,7 +284,9 @@
"marketing": "マーケティング",
"members": "メンバー",
"members_and_teams": "メンバー&チーム",
"membership": "メンバーシップ",
"membership_not_found": "メンバーシップが見つかりません",
"meta": "メタ",
"metadata": "メタデータ",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
@@ -323,10 +326,9 @@
"or": "または",
"organization": "組織",
"organization_id": "組織ID",
"organization_not_found": "組織が見つかりません",
"organization_settings": "組織設定",
"organization_teams_not_found": "組織のチームが見つかりません",
"other": "その他",
"other_filters": "その他のフィルター",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",
@@ -418,7 +420,6 @@
"survey_id": "フォームID",
"survey_languages": "フォームの言語",
"survey_live": "フォーム公開中",
"survey_not_found": "フォームが見つかりません",
"survey_paused": "フォームは一時停止中です。",
"survey_type": "フォームの種類",
"surveys": "フォーム",
@@ -433,7 +434,6 @@
"team_name": "チーム名",
"team_role": "チームの役割",
"teams": "チーム",
"teams_not_found": "チームが見つかりません",
"text": "テキスト",
"time": "時間",
"time_to_finish": "所要時間",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "ユーザー",
"user_id": "ユーザーID",
"user_not_found": "ユーザーが見つかりません",
"variable": "変数",
"variable_ids": "変数ID",
"variables": "変数",
@@ -473,14 +472,13 @@
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"workflows": "ワークフロー",
"workspace": "ワークスペース",
"workspace_configuration": "ワークスペース設定",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
"workspace_creation_description": "アクセス制御を改善するために、フォームをワークスペースで整理します。",
"workspace_id": "ワークスペースID",
"workspace_name": "ワークスペース名",
"workspace_name_placeholder": "例: Formbricks",
"workspace_not_found": "ワークスペースが見つかりません",
"workspace_permission_not_found": "ワークスペースの権限が見つかりません",
"workspaces": "ワークスペース",
"years": "年",
"you": "あなた",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "新しい属性“{key}”を型“{dataType}”で作成しました",
"attributes_msg_userid_already_exists": "この環境にはすでにユーザーIDが存在するため、更新されませんでした。",
"contact_deleted_successfully": "連絡先を正常に削除しました",
"contact_not_found": "そのような連絡先は見つかりません",
"contacts_table_refresh": "連絡先を更新",
"contacts_table_refresh_success": "連絡先を正常に更新しました",
"create_attribute": "属性を作成",
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "Verbinden",
"connect_formbricks": "Sluit Formbricks aan",
"connected": "Aangesloten",
"contact": "Contact",
"contacts": "Contacten",
"continue": "Doorgaan",
"copied": "Gekopieerd",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(kopie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Bewerking",
"elements": "Elementen",
"email": "E-mail",
"ending_card": "Einde kaart",
"enter_url": "URL invoeren",
"enterprise_license": "Enterprise-licentie",
"environment": "Omgeving",
"environment_not_found": "Omgeving niet gevonden",
"environment_notice": "U bevindt zich momenteel in de {environment}-omgeving.",
"error": "Fout",
"error_component_description": "Deze bron bestaat niet of u beschikt niet over de benodigde toegangsrechten.",
@@ -283,7 +284,9 @@
"marketing": "Marketing",
"members": "Leden",
"members_and_teams": "Leden & teams",
"membership": "Lidmaatschap",
"membership_not_found": "Lidmaatschap niet gevonden",
"meta": "Meta",
"metadata": "Metagegevens",
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
@@ -323,10 +326,9 @@
"or": "of",
"organization": "Organisatie",
"organization_id": "Organisatie-ID",
"organization_not_found": "Organisatie niet gevonden",
"organization_settings": "Organisatie-instellingen",
"organization_teams_not_found": "Organisatieteams niet gevonden",
"other": "Ander",
"other_filters": "Overige filters",
"others": "Anderen",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
@@ -418,7 +420,6 @@
"survey_id": "Enquête-ID",
"survey_languages": "Enquêtetalen",
"survey_live": "Enquête live",
"survey_not_found": "Enquête niet gevonden",
"survey_paused": "Enquête onderbroken.",
"survey_type": "Enquêtetype",
"surveys": "Enquêtes",
@@ -433,7 +434,6 @@
"team_name": "Teamnaam",
"team_role": "Teamrol",
"teams": "Teams",
"teams_not_found": "Teams niet gevonden",
"text": "Tekst",
"time": "Tijd",
"time_to_finish": "Tijd om af te ronden",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "Gebruiker",
"user_id": "Gebruikers-ID",
"user_not_found": "Gebruiker niet gevonden",
"variable": "Variabel",
"variable_ids": "Variabele ID's",
"variables": "Variabelen",
@@ -473,14 +472,13 @@
"weeks": "weken",
"welcome_card": "Welkomstkaart",
"workflows": "Workflows",
"workspace": "Werkruimte",
"workspace_configuration": "Werkruimte-configuratie",
"workspace_created_successfully": "Project succesvol aangemaakt",
"workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.",
"workspace_id": "Werkruimte-ID",
"workspace_name": "Werkruimtenaam",
"workspace_name_placeholder": "bijv. Formbricks",
"workspace_not_found": "Werkruimte niet gevonden",
"workspace_permission_not_found": "Werkruimte-machtiging niet gevonden",
"workspaces": "Werkruimtes",
"years": "jaren",
"you": "Jij",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "Nieuw attribuut “{key}” aangemaakt met type “{dataType}”",
"attributes_msg_userid_already_exists": "De gebruikers-ID bestaat al voor deze omgeving en is niet bijgewerkt.",
"contact_deleted_successfully": "Contact succesvol verwijderd",
"contact_not_found": "Er is geen dergelijk contact gevonden",
"contacts_table_refresh": "Vernieuw contacten",
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
"create_attribute": "Attribuut aanmaken",
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "Conectar",
"connect_formbricks": "Conectar Formbricks",
"connected": "conectado",
"contact": "Contato",
"contacts": "Contatos",
"continue": "Continuar",
"copied": "Copiado",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Inserir URL",
"enterprise_license": "Licença Empresarial",
"environment": "Ambiente",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
"error": "Erro",
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
@@ -283,7 +284,9 @@
"marketing": "marketing",
"members": "Membros",
"members_and_teams": "Membros e equipes",
"membership": "Associação",
"membership_not_found": "Assinatura não encontrada",
"meta": "Meta",
"metadata": "metadados",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
@@ -323,10 +326,9 @@
"or": "ou",
"organization": "organização",
"organization_id": "ID da Organização",
"organization_not_found": "Organização não encontrada",
"organization_settings": "Configurações da Organização",
"organization_teams_not_found": "Equipes da organização não encontradas",
"other": "outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",
@@ -418,7 +420,6 @@
"survey_id": "ID da Pesquisa",
"survey_languages": "Idiomas da Pesquisa",
"survey_live": "Pesquisa ao vivo",
"survey_not_found": "Pesquisa não encontrada",
"survey_paused": "Pesquisa pausada.",
"survey_type": "Tipo de Pesquisa",
"surveys": "Pesquisas",
@@ -433,7 +434,6 @@
"team_name": "Nome da equipe",
"team_role": "Função na equipe",
"teams": "Equipes",
"teams_not_found": "Equipes não encontradas",
"text": "Texto",
"time": "tempo",
"time_to_finish": "Hora de terminar",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "Usuário",
"user_id": "ID do usuário",
"user_not_found": "Usuário não encontrado",
"variable": "variável",
"variable_ids": "IDs de variáveis",
"variables": "Variáveis",
@@ -473,14 +472,13 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace": "Espaço de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
"workspace_id": "ID do projeto",
"workspace_name": "Nome do projeto",
"workspace_name_placeholder": "ex: Formbricks",
"workspace_not_found": "Projeto não encontrado",
"workspace_permission_not_found": "Permissão do projeto não encontrada",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "Novo atributo “{key}” criado com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de usuário já existe para este ambiente e não foi atualizado.",
"contact_deleted_successfully": "Contato excluído com sucesso",
"contact_not_found": "Nenhum contato encontrado",
"contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"create_attribute": "Criar atributo",
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "Conectar",
"connect_formbricks": "Ligar Formbricks",
"connected": "Conectado",
"contact": "Contacto",
"contacts": "Contactos",
"continue": "Continuar",
"copied": "Copiado",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"elements": "Elementos",
"email": "Email",
"ending_card": "Cartão de encerramento",
"enter_url": "Introduzir URL",
"enterprise_license": "Licença Enterprise",
"environment": "Ambiente",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
"error": "Erro",
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
@@ -283,7 +284,9 @@
"marketing": "Marketing",
"members": "Membros",
"members_and_teams": "Membros e equipas",
"membership": "Subscrição",
"membership_not_found": "Associação não encontrada",
"meta": "Meta",
"metadata": "Metadados",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
@@ -323,10 +326,9 @@
"or": "ou",
"organization": "Organização",
"organization_id": "ID da Organização",
"organization_not_found": "Organização não encontrada",
"organization_settings": "Configurações da Organização",
"organization_teams_not_found": "Equipas da organização não encontradas",
"other": "Outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",
@@ -418,7 +420,6 @@
"survey_id": "ID do Inquérito",
"survey_languages": "Idiomas da Pesquisa",
"survey_live": "Inquérito ao vivo",
"survey_not_found": "Inquérito não encontrado",
"survey_paused": "Inquérito pausado.",
"survey_type": "Tipo de Inquérito",
"surveys": "Inquéritos",
@@ -433,7 +434,6 @@
"team_name": "Nome da equipa",
"team_role": "Função na equipa",
"teams": "Equipas",
"teams_not_found": "Equipas não encontradas",
"text": "Texto",
"time": "Tempo",
"time_to_finish": "Tempo para concluir",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "Utilizador",
"user_id": "ID do Utilizador",
"user_not_found": "Utilizador não encontrado",
"variable": "Variável",
"variable_ids": "IDs de variáveis",
"variables": "Variáveis",
@@ -473,14 +472,13 @@
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace": "Espaço de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize inquéritos em projetos para melhor controlo de acesso.",
"workspace_id": "ID do projeto",
"workspace_name": "Nome do projeto",
"workspace_name_placeholder": "ex. Formbricks",
"workspace_not_found": "Projeto não encontrado",
"workspace_permission_not_found": "Permissão do projeto não encontrada",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "Criado novo atributo “{key}” com tipo “{dataType}”",
"attributes_msg_userid_already_exists": "O ID de utilizador já existe para este ambiente e não foi atualizado.",
"contact_deleted_successfully": "Contacto eliminado com sucesso",
"contact_not_found": "Nenhum contacto encontrado",
"contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"create_attribute": "Criar atributo",
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "Conectează",
"connect_formbricks": "Conectează Formbricks",
"connected": "Conectat",
"contact": "Contact",
"contacts": "Contacte",
"continue": "Continuă",
"copied": "Copiat",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "Comerț electronic",
"edit": "Editare",
"elements": "Elemente",
"email": "Email",
"ending_card": "Cardul de finalizare",
"enter_url": "Introduceți URL-ul",
"enterprise_license": "Licență Întreprindere",
"environment": "Mediu",
"environment_not_found": "Mediul nu a fost găsit",
"environment_notice": "Te afli în prezent în mediul {environment}",
"error": "Eroare",
"error_component_description": "Această resursă nu există sau nu aveți drepturile necesare pentru a o accesa.",
@@ -283,7 +284,9 @@
"marketing": "Marketing",
"members": "Membri",
"members_and_teams": "Membri și echipe",
"membership": "Abonament",
"membership_not_found": "Apartenența nu a fost găsită",
"meta": "Meta",
"metadata": "Metadate",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
"mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
@@ -323,10 +326,9 @@
"or": "sau",
"organization": "Organizație",
"organization_id": "ID Organizație",
"organization_not_found": "Organizația nu a fost găsită",
"organization_settings": "Setări Organizație",
"organization_teams_not_found": "Echipele organizației nu au fost găsite",
"other": "Altele",
"other_filters": "Alte Filtre",
"others": "Altele",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",
@@ -418,7 +420,6 @@
"survey_id": "ID Chestionar",
"survey_languages": "Limbi chestionar",
"survey_live": "Chestionar activ",
"survey_not_found": "Sondajul nu a fost găsit",
"survey_paused": "Chestionar oprit.",
"survey_type": "Tip Chestionar",
"surveys": "Sondaje",
@@ -433,7 +434,6 @@
"team_name": "Nume echipă",
"team_role": "Rol în echipă",
"teams": "Echipe",
"teams_not_found": "Echipele nu au fost găsite",
"text": "Text",
"time": "Timp",
"time_to_finish": "Timp până la finalizare",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "Utilizator",
"user_id": "ID Utilizator",
"user_not_found": "Utilizatorul nu a fost găsit",
"variable": "Variabilă",
"variable_ids": "ID-uri variabile",
"variables": "Variante",
@@ -473,14 +472,13 @@
"weeks": "săptămâni",
"welcome_card": "Card de bun venit",
"workflows": "Workflows",
"workspace": "Spațiu de lucru",
"workspace_configuration": "Configurare workspace",
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
"workspace_creation_description": "Organizează sondajele în workspaces pentru un control mai bun al accesului.",
"workspace_id": "ID workspace",
"workspace_name": "Nume workspace",
"workspace_name_placeholder": "ex: Formbricks",
"workspace_not_found": "Workspace-ul nu a fost găsit",
"workspace_permission_not_found": "Permisiunea pentru workspace nu a fost găsită",
"workspaces": "Workspaces",
"years": "ani",
"you": "Tu",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "A fost creat un nou atribut „{key}” cu tipul „{dataType}”",
"attributes_msg_userid_already_exists": "ID-ul de utilizator există deja pentru acest mediu și nu a fost actualizat.",
"contact_deleted_successfully": "Contact șters cu succes",
"contact_not_found": "Nu a fost găsit niciun contact",
"contacts_table_refresh": "Reîmprospătare contacte",
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
"create_attribute": "Creează atribut",
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "Подключить",
"connect_formbricks": "Подключить Formbricks",
"connected": "Подключено",
"contact": "Контакт",
"contacts": "Контакты",
"continue": "Продолжить",
"copied": "Скопировано",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(копия {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Редактировать",
"elements": "Элементы",
"email": "Email",
"ending_card": "Завершающая карточка",
"enter_url": "Введите URL",
"enterprise_license": "Корпоративная лицензия",
"environment": "Окружение",
"environment_not_found": "Среда не найдена",
"environment_notice": "В данный момент вы находитесь в среде {environment}.",
"error": "Ошибка",
"error_component_description": "Этот ресурс не существует или у вас нет необходимых прав для доступа к нему.",
@@ -283,7 +284,9 @@
"marketing": "Маркетинг",
"members": "Участники",
"members_and_teams": "Участники и команды",
"membership": "Членство",
"membership_not_found": "Участие не найдено",
"meta": "Мета",
"metadata": "Метаданные",
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
@@ -323,10 +326,9 @@
"or": "или",
"organization": "Организация",
"organization_id": "ID организации",
"organization_not_found": "Организация не найдена",
"organization_settings": "Настройки организации",
"organization_teams_not_found": "Команды организации не найдены",
"other": "Другое",
"other_filters": "Другие фильтры",
"others": "Другие",
"overlay_color": "Цвет наложения",
"overview": "Обзор",
@@ -418,7 +420,6 @@
"survey_id": "ID опроса",
"survey_languages": "Языки опроса",
"survey_live": "Опрос активен",
"survey_not_found": "Опрос не найден",
"survey_paused": "Опрос приостановлен.",
"survey_type": "Тип опроса",
"surveys": "Опросы",
@@ -433,7 +434,6 @@
"team_name": "Название команды",
"team_role": "Роль в команде",
"teams": "Команды",
"teams_not_found": "Команды не найдены",
"text": "Текст",
"time": "Время",
"time_to_finish": "Время до завершения",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "Пользователь",
"user_id": "ID пользователя",
"user_not_found": "Пользователь не найден",
"variable": "Переменная",
"variable_ids": "ID переменных",
"variables": "Переменные",
@@ -473,14 +472,13 @@
"weeks": "недели",
"welcome_card": "Приветственная карточка",
"workflows": "Воркфлоу",
"workspace": "Рабочее пространство",
"workspace_configuration": "Настройка рабочего пространства",
"workspace_created_successfully": "Рабочий проект успешно создан",
"workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.",
"workspace_id": "ID рабочего пространства",
"workspace_name": "Название рабочего пространства",
"workspace_name_placeholder": "например, Formbricks",
"workspace_not_found": "Рабочее пространство не найдено",
"workspace_permission_not_found": "Разрешение на рабочее пространство не найдено",
"workspaces": "Рабочие пространства",
"years": "годы",
"you": "Вы",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
"attributes_msg_userid_already_exists": "Этот user ID уже существует в данной среде и не был обновлён.",
"contact_deleted_successfully": "Контакт успешно удалён",
"contact_not_found": "Такой контакт не найден",
"contacts_table_refresh": "Обновить контакты",
"contacts_table_refresh_success": "Контакты успешно обновлены",
"create_attribute": "Создать атрибут",
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "Anslut",
"connect_formbricks": "Anslut Formbricks",
"connected": "Ansluten",
"contact": "Kontakt",
"contacts": "Kontakter",
"continue": "Fortsätt",
"copied": "Kopierad",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(kopia {copyNumber})",
"e_commerce": "E-handel",
"edit": "Redigera",
"elements": "Element",
"email": "E-post",
"ending_card": "Avslutningskort",
"enter_url": "Ange URL",
"enterprise_license": "Företagslicens",
"environment": "Miljö",
"environment_not_found": "Miljö hittades inte",
"environment_notice": "Du är för närvarande i {environment}-miljön.",
"error": "Fel",
"error_component_description": "Denna resurs finns inte eller så har du inte de nödvändiga rättigheterna för att komma åt den.",
@@ -283,7 +284,9 @@
"marketing": "Marknadsföring",
"members": "Medlemmar",
"members_and_teams": "Medlemmar och team",
"membership": "Medlemskap",
"membership_not_found": "Medlemskap hittades inte",
"meta": "Meta",
"metadata": "Metadata",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
"mobile_overlay_surveys_look_good": "Oroa dig inte dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
@@ -323,10 +326,9 @@
"or": "eller",
"organization": "Organisation",
"organization_id": "Organisations-ID",
"organization_not_found": "Organisation hittades inte",
"organization_settings": "Organisationsinställningar",
"organization_teams_not_found": "Organisationsteam hittades inte",
"other": "Annat",
"other_filters": "Andra filter",
"others": "Andra",
"overlay_color": "Overlay-färg",
"overview": "Översikt",
@@ -418,7 +420,6 @@
"survey_id": "Enkät-ID",
"survey_languages": "Enkätspråk",
"survey_live": "Enkät live",
"survey_not_found": "Enkät hittades inte",
"survey_paused": "Enkät pausad.",
"survey_type": "Enkättyp",
"surveys": "Enkäter",
@@ -433,7 +434,6 @@
"team_name": "Teamnamn",
"team_role": "Teamroll",
"teams": "Åtkomstkontroll",
"teams_not_found": "Team hittades inte",
"text": "Text",
"time": "Tid",
"time_to_finish": "Tid att slutföra",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "Användare",
"user_id": "Användar-ID",
"user_not_found": "Användare hittades inte",
"variable": "Variabel",
"variable_ids": "Variabel-ID:n",
"variables": "Variabler",
@@ -473,14 +472,13 @@
"weeks": "veckor",
"welcome_card": "Välkomstkort",
"workflows": "Arbetsflöden",
"workspace": "Arbetsyta",
"workspace_configuration": "Arbetsytans konfiguration",
"workspace_created_successfully": "Arbetsytan har skapats",
"workspace_creation_description": "Organisera enkäter i arbetsytor för bättre åtkomstkontroll.",
"workspace_id": "Arbetsyte-ID",
"workspace_name": "Arbetsytans namn",
"workspace_name_placeholder": "t.ex. Formbricks",
"workspace_not_found": "Arbetsyta hittades inte",
"workspace_permission_not_found": "Arbetsytebehörighet hittades inte",
"workspaces": "Arbetsytor",
"years": "år",
"you": "Du",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "Nytt attribut ”{key}” med typen ”{dataType}” har skapats",
"attributes_msg_userid_already_exists": "Användar-ID finns redan för denna miljö och uppdaterades inte.",
"contact_deleted_successfully": "Kontakt borttagen",
"contact_not_found": "Ingen sådan kontakt hittades",
"contacts_table_refresh": "Uppdatera kontakter",
"contacts_table_refresh_success": "Kontakter uppdaterade",
"create_attribute": "Skapa attribut",
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "连接",
"connect_formbricks": "连接 Formbricks",
"connected": "已连接",
"contact": "联系人",
"contacts": "联系人",
"continue": "继续",
"copied": "已复制",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(副本 {copyNumber}",
"e_commerce": "电子商务",
"edit": "编辑",
"elements": "元素",
"email": "邮箱",
"ending_card": "结尾卡片",
"enter_url": "输入 URL",
"enterprise_license": "企业 许可证",
"environment": "环境",
"environment_not_found": "环境 未找到",
"environment_notice": "你 目前 位于 {environment} 环境。",
"error": "错误",
"error_component_description": "这个资源不存在或您没有权限访问它。",
@@ -283,7 +284,9 @@
"marketing": "市场营销",
"members": "成员",
"members_and_teams": "成员和团队",
"membership": "会员资格",
"membership_not_found": "未找到会员资格",
"meta": "元数据",
"metadata": "元数据",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
@@ -323,10 +326,9 @@
"or": "或",
"organization": "组织",
"organization_id": "组织 ID",
"organization_not_found": "组织 未找到",
"organization_settings": "组织 设置",
"organization_teams_not_found": "未找到 组织 团队",
"other": "其他",
"other_filters": "其他筛选条件",
"others": "其他",
"overlay_color": "覆盖层颜色",
"overview": "概览",
@@ -418,7 +420,6 @@
"survey_id": "调查 ID",
"survey_languages": "调查 语言",
"survey_live": "调查 运行中",
"survey_not_found": "调查 未找到",
"survey_paused": "调查 暂停。",
"survey_type": "调查 类型",
"surveys": "调查",
@@ -433,7 +434,6 @@
"team_name": "团队 名称",
"team_role": "团队角色",
"teams": "团队",
"teams_not_found": "未找到 团队",
"text": "文本",
"time": "时间",
"time_to_finish": "完成 时间",
@@ -457,7 +457,6 @@
"url": "URL",
"user": "用户",
"user_id": "用户 ID",
"user_not_found": "用户 不存在",
"variable": "变量",
"variable_ids": "变量 ID",
"variables": "变量",
@@ -473,14 +472,13 @@
"weeks": "周",
"welcome_card": "欢迎 卡片",
"workflows": "工作流",
"workspace": "工作区",
"workspace_configuration": "工作区配置",
"workspace_created_successfully": "工作区创建成功",
"workspace_creation_description": "在工作区中组织调查,以便更好地进行访问控制。",
"workspace_id": "工作区 ID",
"workspace_name": "工作区名称",
"workspace_name_placeholder": "例如:Formbricks",
"workspace_not_found": "未找到工作区",
"workspace_permission_not_found": "未找到工作区权限",
"workspaces": "工作区",
"years": "年",
"you": "你 ",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "已创建新属性“{key}”,类型为“{dataType}”",
"attributes_msg_userid_already_exists": "该环境下的用户ID已存在,未进行更新。",
"contact_deleted_successfully": "联系人 删除 成功",
"contact_not_found": "未找到此 联系人",
"contacts_table_refresh": "刷新 联系人",
"contacts_table_refresh_success": "联系人 已成功刷新",
"create_attribute": "创建属性",
+6 -9
View File
@@ -167,6 +167,7 @@
"connect": "連線",
"connect_formbricks": "連線 Formbricks",
"connected": "已連線",
"contact": "聯絡人",
"contacts": "聯絡人",
"continue": "繼續",
"copied": "已 複製",
@@ -214,12 +215,12 @@
"duplicate_copy_number": "(複製 {copyNumber}",
"e_commerce": "電子商務",
"edit": "編輯",
"elements": "元素",
"email": "電子郵件",
"ending_card": "結尾卡片",
"enter_url": "輸入 URL",
"enterprise_license": "企業授權",
"environment": "環境",
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
"error": "錯誤",
"error_component_description": "此資源不存在或您沒有存取權限。",
@@ -283,7 +284,9 @@
"marketing": "行銷",
"members": "成員",
"members_and_teams": "成員與團隊",
"membership": "會員資格",
"membership_not_found": "找不到成員資格",
"meta": "Meta",
"metadata": "元數據",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
@@ -323,10 +326,9 @@
"or": "或",
"organization": "組織",
"organization_id": "組織 ID",
"organization_not_found": "找不到組織",
"organization_settings": "組織設定",
"organization_teams_not_found": "找不到組織團隊",
"other": "其他",
"other_filters": "其他篩選條件",
"others": "其他",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",
@@ -418,7 +420,6 @@
"survey_id": "問卷 ID",
"survey_languages": "問卷語言",
"survey_live": "問卷已上線",
"survey_not_found": "找不到問卷",
"survey_paused": "問卷已暫停。",
"survey_type": "問卷類型",
"surveys": "問卷",
@@ -433,7 +434,6 @@
"team_name": "團隊名稱",
"team_role": "團隊角色",
"teams": "團隊",
"teams_not_found": "找不到團隊",
"text": "文字",
"time": "時間",
"time_to_finish": "完成時間",
@@ -457,7 +457,6 @@
"url": "網址",
"user": "使用者",
"user_id": "使用者 ID",
"user_not_found": "找不到使用者",
"variable": "變數",
"variable_ids": "變數 ID",
"variables": "變數",
@@ -473,14 +472,13 @@
"weeks": "週",
"welcome_card": "歡迎卡片",
"workflows": "工作流程",
"workspace": "工作區",
"workspace_configuration": "工作區設定",
"workspace_created_successfully": "工作區已成功建立",
"workspace_creation_description": "將問卷組織在工作區中,以便更好地控管存取權限。",
"workspace_id": "工作區 ID",
"workspace_name": "工作區名稱",
"workspace_name_placeholder": "例如:Formbricks",
"workspace_not_found": "找不到工作區",
"workspace_permission_not_found": "找不到工作區權限",
"workspaces": "工作區",
"years": "年",
"you": "您",
@@ -665,7 +663,6 @@
"attributes_msg_new_attribute_created": "已建立新屬性「{key}」,型別為「{dataType}」",
"attributes_msg_userid_already_exists": "此環境已存在該使用者 ID,未進行更新。",
"contact_deleted_successfully": "聯絡人已成功刪除",
"contact_not_found": "找不到此聯絡人",
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"create_attribute": "建立屬性",
@@ -1,6 +1,6 @@
import { Languages } from "lucide-react";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getEnabledLanguages } from "@/lib/i18n/utils";
@@ -18,11 +18,7 @@ interface LanguageDropdownProps {
locale: TUserLocale;
}
export const LanguageDropdown = ({
survey,
setLanguage,
locale,
}: LanguageDropdownProps) => {
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
const { t } = useTranslation();
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
@@ -33,7 +29,10 @@ export const LanguageDropdown = ({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" title={t("common.select_language")} aria-label={t("common.select_language")}>
<Button
variant="secondary"
title={t("common.select_language")}
aria-label={t("common.select_language")}>
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
@@ -2,6 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { createTag } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
@@ -68,7 +69,7 @@ export const createTagToResponseAction = authenticatedActionClient
const tagEnvironment = await getTag(parsedInput.tagId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
throw new ResourceNotFoundError("Environment", null);
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
@@ -113,7 +114,7 @@ export const deleteTagOnResponseAction = authenticatedActionClient
const tagEnvironment = await getTag(parsedInput.tagId);
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
throw new ResourceNotFoundError("Environment", null);
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
@@ -31,6 +31,8 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
environmentId: true,
questions: true,
blocks: true,
startsAt: true,
endsAt: true,
endings: true,
hiddenFields: true,
variables: true,
@@ -59,6 +61,8 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
displayLimit: true,
autoClose: true,
autoComplete: true,
startsAt: true,
endsAt: true,
surveyClosedMessage: true,
styling: true,
projectOverwrites: true,
@@ -1313,11 +1313,26 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
);
await Promise.all(
hobbySubscriptions.map(({ subscription }) =>
client.subscriptions.cancel(subscription.id, {
prorate: false,
})
)
hobbySubscriptions.map(async ({ subscription }) => {
try {
await client.subscriptions.cancel(subscription.id, {
prorate: false,
});
} catch (err) {
if (
err instanceof Stripe.errors.StripeInvalidRequestError &&
err.statusCode === 404 &&
err.code === "resource_missing"
) {
logger.warn(
{ subscriptionId: subscription.id, organizationId },
"Subscription already deleted, skipping cancel"
);
return;
}
throw err;
}
})
);
return;
}
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { TEnvironment } from "@formbricks/types/environment";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { DEFAULT_LOCALE } from "@/lib/constants";
@@ -35,12 +36,12 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
const t = await getTranslate();
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!responses) {
@@ -49,7 +50,7 @@ export const ActivitySection = async ({ environment, contactId, environmentTags
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getDisplaysByContactId } from "@/lib/display/service";
import { getResponsesByContactId } from "@/lib/response/service";
import { getLocale } from "@/lingodotdev/language";
@@ -17,7 +18,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
]);
if (!contact) {
throw new Error(t("environments.contacts.contact_not_found"));
throw new ResourceNotFoundError(t("common.contact"), contactId);
}
const [responses, displays] = await Promise.all([
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getTranslate } from "@/lingodotdev/server";
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
@@ -31,7 +32,7 @@ export const SingleContactPage = async (props: {
]);
if (!contact) {
throw new Error(t("environments.contacts.contact_not_found"));
throw new ResourceNotFoundError(t("common.contact"), params.contactId);
}
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
+2 -1
View File
@@ -3,6 +3,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
@@ -164,7 +165,7 @@ export const updateContactAttributesAction = authenticatedActionClient
// Get contact to access environmentId for revalidation
const contact = await getContact(parsedInput.contactId);
if (!contact) {
throw new Error("Contact not found");
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
}
const result = await updateContactAttributes(parsedInput.contactId, parsedInput.attributes);
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -20,7 +21,7 @@ export const ContactsSecondaryNavigation = async ({
project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -24,11 +24,11 @@ const ConfigLayout = async (props: {
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
@@ -45,7 +45,7 @@ const ConfigLayout = async (props: {
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
return children;
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
import { getOrganization } from "@/lib/organization/service";
import { loadNewSegmentInSurvey } from "@/lib/survey/service";
@@ -35,7 +35,7 @@ const checkAdvancedTargetingPermission = async (organizationId: string) => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
throw new ResourceNotFoundError("Organization", organizationId);
}
const isContactsEnabled = await getIsContactsEnabled(organizationId);
@@ -2,7 +2,12 @@
import { z } from "zod";
import { ZId, ZUuid } from "@formbricks/types/common";
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import {
AuthenticationError,
OperationNotAllowedError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { ZMembershipUpdateInput } from "@formbricks/types/memberships";
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
@@ -21,7 +26,7 @@ import { getInvite } from "@/modules/organization/settings/teams/lib/invite";
export const checkRoleManagementPermission = async (organizationId: string) => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
throw new ResourceNotFoundError("Organization", organizationId);
}
const isAccessControlAllowed = await getAccessControlPermission(organizationId);
+2 -1
View File
@@ -4,6 +4,7 @@ import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { DEFAULT_TEAM_ID } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { validateInputs } from "@/lib/utils/validate";
@@ -41,7 +42,7 @@ const getTeam = reactCache(async (teamId: string): Promise<Team> => {
});
if (!team) {
throw new Error("Team not found");
throw new ResourceNotFoundError("Team", teamId);
}
return team;
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getTranslate } from "@/lingodotdev/server";
import { AccessView } from "@/modules/ee/teams/project-teams/components/access-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -15,7 +16,7 @@ export const ProjectTeams = async (props: { params: Promise<{ environmentId: str
const teams = await getTeamsByProjectId(project.id);
if (!teams) {
throw new Error(t("common.teams_not_found"));
throw new ResourceNotFoundError(t("common.teams"), null);
}
return (
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
@@ -32,7 +33,7 @@ export const TeamsView = async ({
]);
if (!teams) {
throw new Error(t("common.teams_not_found"));
throw new ResourceNotFoundError(t("common.teams"), null);
}
const buttons: [ModalButton, ModalButton] = [
@@ -301,7 +301,7 @@ describe("updateTeamDetails", () => {
updatedAt: new Date(),
});
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null);
await expect(updateTeamDetails("t1", data)).rejects.toThrow("Team not found");
await expect(updateTeamDetails("t1", data)).rejects.toThrow("Team with ID t1 not found");
});
test("throws error if user not in org membership", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
@@ -309,7 +309,7 @@ export const updateTeamDetails = async (teamId: string, data: TTeamSettingsFormS
const currentTeamDetails = await getTeamDetails(teamId);
if (!currentTeamDetails) {
throw new Error("Team not found");
throw new ResourceNotFoundError("Team", teamId);
}
// Check that all users exist within the organization's membership.
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -45,7 +45,7 @@ export const updateProjectBrandingAction = authenticatedActionClient.inputSchema
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
throw new ResourceNotFoundError("Organization", organizationId);
}
const canRemoveBranding = await getRemoveBrandingPermission(organizationId);
+2 -2
View File
@@ -15,7 +15,7 @@ import {
import { TEmailTemplateLegalProps } from "@formbricks/email/src/types/email";
import { logger } from "@formbricks/logger";
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
import { InvalidInputError } from "@formbricks/types/errors";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import type { TResponse } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import type { TSurvey } from "@formbricks/types/surveys/types";
@@ -237,7 +237,7 @@ export const sendResponseFinishedEmail = async (
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error("Organization not found");
throw new ResourceNotFoundError("Organization", null);
}
// Pre-process the element response mapping before passing to email
+15 -11
View File
@@ -4,7 +4,7 @@ import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
@@ -102,6 +102,12 @@ vi.mock("@/lib/constants", () => ({
}));
vi.mock("@formbricks/types/errors", () => ({
AuthenticationError: class AuthenticationError extends Error {
constructor(message: string) {
super(message);
this.name = "AuthenticationError";
}
},
AuthorizationError: class AuthorizationError extends Error {},
DatabaseError: class DatabaseError extends Error {},
ResourceNotFoundError: class ResourceNotFoundError extends Error {
@@ -162,22 +168,22 @@ describe("utils.ts", () => {
test("throws error if project not found", async () => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
await expect(getEnvironmentAuth("env123")).rejects.toThrow("common.workspace_not_found");
await expect(getEnvironmentAuth("env123")).rejects.toThrow(ResourceNotFoundError);
});
test("throws error if environment not found", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(getEnvironmentAuth("env123")).rejects.toThrow("common.environment_not_found");
await expect(getEnvironmentAuth("env123")).rejects.toThrow(ResourceNotFoundError);
});
test("throws error if session not found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
await expect(getEnvironmentAuth("env123")).rejects.toThrow("common.session_not_found");
await expect(getEnvironmentAuth("env123")).rejects.toThrow(AuthenticationError);
});
test("throws error if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(getEnvironmentAuth("env123")).rejects.toThrow("common.organization_not_found");
await expect(getEnvironmentAuth("env123")).rejects.toThrow(ResourceNotFoundError);
});
test("throws AuthorizationError if membership not found", async () => {
@@ -219,7 +225,7 @@ describe("utils.ts", () => {
test("throws error if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(environmentIdLayoutChecks("env123")).rejects.toThrow("common.organization_not_found");
await expect(environmentIdLayoutChecks("env123")).rejects.toThrow(ResourceNotFoundError);
});
});
@@ -454,7 +460,7 @@ describe("utils.ts", () => {
test("throws error if session not found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.session_not_found");
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(AuthenticationError);
});
test("throws error if userId doesn't match session", async () => {
@@ -466,15 +472,13 @@ describe("utils.ts", () => {
test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValueOnce(null);
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow("common.user_not_found");
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(AuthenticationError);
});
test("throws error if environment data not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValueOnce(null);
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(
"common.environment_not_found"
);
await expect(getEnvironmentLayoutData("env123", "user123")).rejects.toThrow(ResourceNotFoundError);
});
test("throws AuthorizationError if user has no environment access", async () => {
+15 -10
View File
@@ -4,7 +4,12 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError, DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
AuthenticationError,
AuthorizationError,
DatabaseError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getEnvironment } from "@/lib/environment/service";
@@ -43,19 +48,19 @@ export const getEnvironmentAuth = reactCache(async (environmentId: string): Prom
]);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), environmentId);
}
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
@@ -109,7 +114,7 @@ export const environmentIdLayoutChecks = async (environmentId: string) => {
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
return { t, session, user, organization };
@@ -274,18 +279,18 @@ export const getEnvironmentLayoutData = reactCache(
const session = await getServerSession(authOptions);
if (!session?.user) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
// Verify userId matches session (safety check)
if (session.user.id !== userId) {
throw new Error("User ID mismatch with session");
throw new AuthenticationError("User ID mismatch with session");
}
// Get user first (lightweight query needed for subsequent checks)
const user = await getUser(userId); // 1 DB query
if (!user) {
throw new Error(t("common.user_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
// Authorization check before expensive data fetching
@@ -296,7 +301,7 @@ export const getEnvironmentLayoutData = reactCache(
const relationData = await getEnvironmentWithRelations(environmentId, userId);
if (!relationData) {
throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.environment"), environmentId);
}
const { environment, project, organization, environments, membership } = relationData;
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { describe, expect, test, vi } from "vitest";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
@@ -59,19 +60,19 @@ describe("getOrganizationAuth", () => {
test("throws if session is missing", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
vi.mocked(getOrganization).mockResolvedValue(mockOrg);
await expect(getOrganizationAuth("org-1")).rejects.toThrow("common.session_not_found");
await expect(getOrganizationAuth("org-1")).rejects.toThrow(AuthenticationError);
});
test("throws if organization is missing", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getOrganization).mockResolvedValue(null);
await expect(getOrganizationAuth("org-1")).rejects.toThrow("common.organization_not_found");
await expect(getOrganizationAuth("org-1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws if membership is missing", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getOrganization).mockResolvedValue(mockOrg);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
await expect(getOrganizationAuth("org-1")).rejects.toThrow("common.membership_not_found");
await expect(getOrganizationAuth("org-1")).rejects.toThrow(ResourceNotFoundError);
});
});
+4 -3
View File
@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { cache as reactCache } from "react";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
@@ -23,16 +24,16 @@ export const getOrganizationAuth = reactCache(async (organizationId: string): Pr
]);
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), organizationId);
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
if (!currentUserMembership) {
throw new Error(t("common.membership_not_found"));
throw new ResourceNotFoundError(t("common.membership"), null);
}
const { isMember, isOwner, isManager, isBilling } = getAccessFlags(currentUserMembership?.role);
@@ -1,7 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { type JSX, useState } from "react";
import { useTranslation } from "react-i18next";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { ActionDetailModal } from "./ActionDetailModal";
@@ -2,7 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getOrganization } from "@/lib/organization/service";
@@ -48,7 +48,7 @@ export const updateProjectAction = authenticatedActionClient.inputSchema(ZUpdate
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
throw new ResourceNotFoundError("Organization", organizationId);
}
const canRemoveBranding = await getRemoveBrandingPermission(organizationId);
@@ -1,4 +1,5 @@
import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
@@ -22,11 +23,11 @@ export const DeleteProject = async ({
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
throw new AuthenticationError(t("common.not_authenticated"));
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), null);
}
const availableProjects = organization ? await getUserProjects(session.user.id, organization.id) : null;
@@ -1,3 +1,4 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -16,7 +17,7 @@ export const LanguagesPage = async (props: { params: Promise<{ environmentId: st
const user = await getUser(session.user.id);
if (!user) {
throw new Error("User not found");
throw new AuthenticationError(t("common.not_authenticated"));
}
return (
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { IS_STORAGE_CONFIGURED, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@/lib/constants";
@@ -24,7 +25,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error("Workspace not found");
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const canRemoveBranding = await getRemoveBrandingPermission(organization.id);
@@ -1,284 +1,43 @@
import { ActionClass, Prisma } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey, TSurveyCreateInput } from "@formbricks/types/surveys/types";
import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { selectSurvey } from "@/modules/survey/lib/survey";
createSurvey as createSurveyFromService,
handleTriggerUpdates as handleTriggerUpdatesFromService,
} from "@/lib/survey/service";
import { createSurvey, handleTriggerUpdates } from "./survey";
// Mock dependencies
vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(),
vi.mock("@/lib/survey/service", () => ({
createSurvey: vi.fn(),
handleTriggerUpdates: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
getOrganizationByEnvironmentId: vi.fn(),
}));
describe("template list survey wrappers", () => {
const environmentId = "env_1";
const surveyBody = { name: "Survey" } as TSurveyCreateInput;
const createdSurvey = { id: "survey_1" } as TSurvey;
vi.mock("@/modules/survey/lib/action-class", () => ({
getActionClasses: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
selectSurvey: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
status: true,
environmentId: true,
segment: true,
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
create: vi.fn(),
update: vi.fn(),
},
segment: {
create: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("survey module", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
});
describe("createSurvey", () => {
test("creates a survey successfully", async () => {
// Mock input data
const environmentId = "env-123";
const surveyBody: TSurveyCreateInput = {
name: "Test Survey",
type: "app",
status: "draft",
questions: [],
createdBy: "user-123",
};
// Mock dependencies
const mockActionClasses: ActionClass[] = [];
vi.mocked(getActionClasses).mockResolvedValue(mockActionClasses);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123", name: "Org" } as any);
const mockCreatedSurvey = {
id: "survey-123",
environmentId,
type: "app",
segment: {
surveys: [{ id: "survey-123" }],
},
} as any;
vi.mocked(prisma.survey.create).mockResolvedValue(mockCreatedSurvey);
const mockSegment = { id: "segment-123" } as any;
vi.mocked(prisma.segment.create).mockResolvedValue(mockSegment);
// Execute function
const result = await createSurvey(environmentId, surveyBody);
// Verify results
expect(getActionClasses).toHaveBeenCalledWith(environmentId);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(prisma.survey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
name: surveyBody.name,
type: surveyBody.type,
environment: { connect: { id: environmentId } },
creator: { connect: { id: surveyBody.createdBy } },
}),
select: selectSurvey,
});
expect(prisma.segment.create).toHaveBeenCalled();
expect(prisma.survey.update).toHaveBeenCalled();
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalledWith(
"survey-123",
"user-123",
"org-123"
);
expect(result).toBeDefined();
expect(result.id).toBe("survey-123");
});
test("handles empty languages array", async () => {
const environmentId = "env-123";
const surveyBody: TSurveyCreateInput = {
name: "Test Survey",
type: "app",
status: "draft",
languages: [],
questions: [],
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
vi.mocked(prisma.survey.create).mockResolvedValue({
id: "survey-123",
environmentId,
type: "link",
segment: null,
} as any);
await createSurvey(environmentId, surveyBody);
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.not.objectContaining({ languages: [] }),
})
);
});
test("handles follow-ups properly", async () => {
const environmentId = "env-123";
const surveyBody: TSurveyCreateInput = {
name: "Test Survey",
type: "app",
status: "draft",
questions: [],
followUps: [{ name: "Follow Up 1", trigger: "trigger1", action: "action1" } as any],
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
vi.mocked(prisma.survey.create).mockResolvedValue({
id: "survey-123",
environmentId,
type: "link",
segment: null,
} as any);
await createSurvey(environmentId, surveyBody);
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
followUps: {
create: [{ name: "Follow Up 1", trigger: "trigger1", action: "action1" }],
},
}),
})
);
});
test("throws error when organization not found", async () => {
const environmentId = "env-123";
const surveyBody: TSurveyCreateInput = {
name: "Test Survey",
type: "app",
status: "draft",
questions: [],
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createSurvey(environmentId, surveyBody)).rejects.toThrow(ResourceNotFoundError);
});
test("handles database errors", async () => {
const environmentId = "env-123";
const surveyBody: TSurveyCreateInput = {
name: "Test Survey",
type: "app",
status: "draft",
questions: [],
};
vi.mocked(getActionClasses).mockResolvedValue([]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue({ id: "org-123" } as any);
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.survey.create).mockRejectedValue(prismaError);
await expect(createSurvey(environmentId, surveyBody)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalled();
});
test("re-exports the shared trigger update helper", () => {
expect(handleTriggerUpdates).toBe(handleTriggerUpdatesFromService);
});
describe("handleTriggerUpdates", () => {
test("handles empty triggers", () => {
const result = handleTriggerUpdates(undefined as any, [], []);
expect(result).toEqual({});
});
test("delegates createSurvey to the shared survey service", async () => {
vi.mocked(createSurveyFromService).mockResolvedValueOnce(createdSurvey);
test("adds new triggers", () => {
const updatedTriggers = [
{ actionClass: { id: "action-1" } },
{ actionClass: { id: "action-2" } },
] as any;
const currentTriggers = [] as any;
const actionClasses = [{ id: "action-1" }, { id: "action-2" }] as ActionClass[];
const result = await createSurvey(environmentId, surveyBody);
const result = handleTriggerUpdates(updatedTriggers, currentTriggers, actionClasses);
expect(createSurveyFromService).toHaveBeenCalledWith(environmentId, surveyBody);
expect(result).toBe(createdSurvey);
});
expect(result).toEqual({
create: [{ actionClassId: "action-1" }, { actionClassId: "action-2" }],
});
});
test("propagates service errors", async () => {
const error = new DatabaseError("database error");
vi.mocked(createSurveyFromService).mockRejectedValueOnce(error);
test("removes triggers", () => {
const updatedTriggers = [] as any;
const currentTriggers = [
{ actionClass: { id: "action-1" } },
{ actionClass: { id: "action-2" } },
] as any;
const actionClasses = [{ id: "action-1" }, { id: "action-2" }] as ActionClass[];
const result = handleTriggerUpdates(updatedTriggers, currentTriggers, actionClasses);
expect(result).toEqual({
deleteMany: {
actionClassId: {
in: ["action-1", "action-2"],
},
},
});
});
test("throws error for invalid trigger", () => {
const updatedTriggers = [{ actionClass: { id: "action-3" } }] as any;
const currentTriggers = [] as any;
const actionClasses = [{ id: "action-1" }] as ActionClass[];
expect(() => handleTriggerUpdates(updatedTriggers, currentTriggers, actionClasses)).toThrow(
InvalidInputError
);
});
test("throws error for duplicate triggers", () => {
const updatedTriggers = [
{ actionClass: { id: "action-1" } },
{ actionClass: { id: "action-1" } },
] as any;
const currentTriggers = [] as any;
const actionClasses = [{ id: "action-1" }] as ActionClass[];
expect(() => handleTriggerUpdates(updatedTriggers, currentTriggers, actionClasses)).toThrow(
InvalidInputError
);
});
await expect(createSurvey(environmentId, surveyBody)).rejects.toThrow(error);
});
});
@@ -1,205 +1,11 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey, TSurveyCreateInput } from "@formbricks/types/surveys/types";
import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { selectSurvey } from "@/modules/survey/lib/survey";
import { createSurvey as createSurveyFromService, handleTriggerUpdates } from "@/lib/survey/service";
export { handleTriggerUpdates };
export const createSurvey = async (
environmentId: string,
surveyBody: TSurveyCreateInput
): Promise<TSurvey> => {
try {
const { createdBy, ...restSurveyBody } = surveyBody;
// empty languages array
if (!restSurveyBody.languages?.length) {
delete restSurveyBody.languages;
}
const actionClasses = await getActionClasses(environmentId);
// @ts-expect-error
let data: Omit<Prisma.SurveyCreateInput, "environment"> = {
...restSurveyBody,
// TODO: Create with attributeFilters
triggers: restSurveyBody.triggers
? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
: undefined,
attributeFilters: undefined,
};
if (createdBy) {
data.creator = {
connect: {
id: createdBy,
},
};
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
// Survey follow-ups
if (restSurveyBody.followUps?.length) {
data.followUps = {
create: restSurveyBody.followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
};
} else {
delete data.followUps;
}
// Validate and prepare blocks
if (data.blocks && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
}
const survey = await prisma.survey.create({
data: {
...data,
environment: {
connect: {
id: environmentId,
},
},
},
select: selectSurvey,
});
// if the survey created is an "app" survey, we also create a private segment for it.
if (survey.type === "app") {
const newSegment = await prisma.segment.create({
data: {
title: survey.id,
filters: [],
isPrivate: true,
environment: {
connect: {
id: environmentId,
},
},
},
});
await prisma.survey.update({
where: {
id: survey.id,
},
data: {
segment: {
connect: {
id: newSegment.id,
},
},
},
});
}
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
// @ts-expect-error
const transformedSurvey: TSurvey = {
...survey,
...(survey.segment && {
segment: {
...survey.segment,
surveys: survey.segment.surveys.map((survey) => survey.id),
},
}),
};
if (createdBy) {
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
}
return transformedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error creating survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
const getTriggerIds = (triggers: unknown): string[] | null => {
if (!triggers) return null;
if (!Array.isArray(triggers)) {
throw new InvalidInputError("Invalid trigger id");
}
return triggers.map((trigger) => {
const actionClassId = (trigger as { actionClass?: { id?: unknown } })?.actionClass?.id;
if (typeof actionClassId !== "string") {
throw new InvalidInputError("Invalid trigger id");
}
return actionClassId;
});
};
const checkTriggersValidity = (triggers: unknown, actionClasses: Array<{ id: string }>) => {
const triggerIds = getTriggerIds(triggers);
if (!triggerIds) return;
// check if all the triggers are valid
triggerIds.forEach((triggerId) => {
if (!actionClasses.find((actionClass) => actionClass.id === triggerId)) {
throw new InvalidInputError("Invalid trigger id");
}
});
if (new Set(triggerIds).size !== triggerIds.length) {
throw new InvalidInputError("Duplicate trigger id");
}
};
export const handleTriggerUpdates = (
updatedTriggers: unknown,
currentTriggers: unknown,
actionClasses: Array<{ id: string }>
) => {
const updatedTriggerIds = getTriggerIds(updatedTriggers);
if (!updatedTriggerIds) return {};
checkTriggersValidity(updatedTriggers, actionClasses);
const currentTriggerIds = getTriggerIds(currentTriggers) ?? [];
// added triggers are triggers that are not in the current triggers and are there in the new triggers
const addedTriggerIds = updatedTriggerIds.filter((triggerId) => !currentTriggerIds.includes(triggerId));
// deleted triggers are triggers that are not in the new triggers and are there in the current triggers
const deletedTriggerIds = currentTriggerIds.filter((triggerId) => !updatedTriggerIds.includes(triggerId));
// Construct the triggers update object
const triggersUpdate: TriggerUpdate = {};
if (addedTriggerIds.length > 0) {
triggersUpdate.create = addedTriggerIds.map((triggerId) => ({
actionClassId: triggerId,
}));
}
if (deletedTriggerIds.length > 0) {
// disconnect the public triggers from the survey
triggersUpdate.deleteMany = {
actionClassId: {
in: deletedTriggerIds,
},
};
}
return triggersUpdate;
return createSurveyFromService(environmentId, surveyBody);
};
@@ -128,14 +128,21 @@ export const EditWelcomeCard = ({
id="welcome-card-image"
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId}
onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => {
if (url?.length) {
updateSurvey({ fileUrl: url[0] });
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
if (url?.length && url[0]) {
const update =
fileType === "video"
? { videoUrl: url[0], fileUrl: undefined }
: { fileUrl: url[0], videoUrl: undefined };
updateSurvey(update);
} else {
updateSurvey({ fileUrl: undefined });
updateSurvey({ fileUrl: undefined, videoUrl: undefined });
}
}}
fileUrl={localSurvey?.welcomeCard?.fileUrl}
videoUrl={localSurvey?.welcomeCard?.videoUrl}
isVideoAllowed={true}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
</div>
+35 -813
View File
@@ -1,837 +1,59 @@
import { ActionClass, Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { updateSurveyInternal } from "@/lib/survey/service";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getSurvey } from "@/modules/survey/lib/survey";
import { checkTriggersValidity, handleTriggerUpdates, updateSurvey, updateSurveyDraft } from "./survey";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
update: vi.fn(),
},
segment: {
update: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(),
}));
import { beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
handleTriggerUpdates as handleTriggerUpdatesFromService,
updateSurvey as updateSurveyFromService,
updateSurveyInternal,
} from "@/lib/survey/service";
import { handleTriggerUpdates, updateSurvey, updateSurveyDraft } from "./survey";
vi.mock("@/lib/survey/service", () => ({
handleTriggerUpdates: vi.fn(),
updateSurvey: vi.fn(),
updateSurveyInternal: vi.fn(),
}));
vi.mock("@/modules/survey/lib/action-class", () => ({
getActionClasses: vi.fn(),
}));
describe("survey editor wrappers", () => {
const survey = { id: "survey_1" } as TSurvey;
vi.mock("@/modules/survey/lib/organization", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getOrganizationAIKeys: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
getSurvey: vi.fn(),
selectSurvey: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
environmentId: true,
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("Survey Editor Library Tests", () => {
afterEach(() => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("updateSurvey", () => {
const mockSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "app",
environmentId: "env123",
createdBy: "user123",
status: "draft",
displayOption: "displayOnce",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
],
welcomeCard: {
enabled: false,
timeToFinish: true,
showResponseCount: false,
},
triggers: [],
endings: [],
hiddenFields: { enabled: false },
delay: 0,
autoComplete: null,
projectOverwrites: null,
styling: null,
showLanguageSwitch: false,
segment: null,
surveyClosedMessage: null,
singleUse: null,
isVerifyEmailEnabled: false,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
pin: null,
displayPercentage: null,
languages: [
{
language: {
id: "en",
code: "en",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "project1",
},
default: true,
enabled: true,
},
],
variables: [],
followUps: [],
} as unknown as TSurvey;
const mockCurrentSurvey = { ...mockSurvey };
const mockActionClasses: ActionClass[] = [
{
id: "action1",
name: "Code Action",
description: "Action from code",
type: "code" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
];
const mockOrganizationId = "org123";
const mockOrganization = {
id: mockOrganizationId,
name: "Test Organization",
ownerUserId: "user123",
billing: {
stripeCustomerId: "cust_123",
features: {},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
};
beforeEach(() => {
vi.mocked(prisma.survey.update).mockResolvedValue(mockSurvey as any);
vi.mocked(prisma.segment.update).mockResolvedValue({
id: "segment1",
environmentId: "env123",
surveys: [{ id: "survey123" }],
} as any);
vi.mocked(getSurvey).mockResolvedValue(mockCurrentSurvey);
vi.mocked(getActionClasses).mockResolvedValue(mockActionClasses);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(mockOrganizationId);
vi.mocked(getOrganizationAIKeys).mockResolvedValue(mockOrganization as any);
});
test("should handle languages update with multiple languages", async () => {
const updatedSurvey: TSurvey = {
...mockSurvey,
languages: [
{
language: {
id: "en",
code: "en",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "project1",
},
default: true,
enabled: true,
},
{
language: {
id: "es",
code: "es",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "project1",
},
default: false,
enabled: true,
},
],
};
await updateSurvey(updatedSurvey);
expect(prisma.survey.update).toHaveBeenCalledWith({
where: { id: "survey123" },
data: expect.objectContaining({
languages: {
updateMany: expect.any(Array),
create: expect.arrayContaining([
expect.objectContaining({
languageId: "es",
default: false,
enabled: true,
}),
]),
},
}),
select: expect.any(Object),
});
});
test("should handle languages update with single default language", async () => {
// This tests the fix for the bug where languages.length === 1 would incorrectly
// set updatedLanguageIds to [] causing the default language to be removed
const updatedSurvey: TSurvey = {
...mockSurvey,
languages: [
{
language: {
id: "en",
code: "en",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "project1",
},
default: true,
enabled: true,
},
],
};
await updateSurvey(updatedSurvey);
// Verify that prisma.survey.update was called
expect(prisma.survey.update).toHaveBeenCalled();
const updateCall = vi.mocked(prisma.survey.update).mock.calls[0][0];
// The key test: when languages.length === 1, we should still process language updates
// and NOT delete the language. Before the fix, languages.length > 1 would fail this case.
expect(updateCall).toBeDefined();
expect(updateCall.where).toEqual({ id: "survey123" });
expect(updateCall.data).toBeDefined();
});
test("should remove all languages when empty array is passed", async () => {
const updatedSurvey: TSurvey = {
...mockSurvey,
languages: [],
};
await updateSurvey(updatedSurvey);
// Verify that prisma.survey.update was called
expect(prisma.survey.update).toHaveBeenCalled();
const updateCall = vi.mocked(prisma.survey.update).mock.calls[0][0];
// When languages is empty array, all existing languages should be removed
expect(updateCall).toBeDefined();
expect(updateCall.where).toEqual({ id: "survey123" });
expect(updateCall.data).toBeDefined();
});
test("should delete private segment for non-app type surveys", async () => {
const mockSegment: TSegment = {
id: "segment1",
title: "Test Segment",
isPrivate: true,
environmentId: "env123",
surveys: ["survey123"],
createdAt: new Date(),
updatedAt: new Date(),
description: null,
filters: [{ id: "filter1" } as any],
};
const updatedSurvey: TSurvey = {
...mockSurvey,
type: "link",
segment: mockSegment,
};
await updateSurvey(updatedSurvey);
expect(prisma.segment.update).toHaveBeenCalledWith({
where: { id: "segment1" },
data: {
surveys: {
disconnect: {
id: "survey123",
},
},
},
});
expect(prisma.segment.delete).toHaveBeenCalledWith({
where: {
id: "segment1",
},
});
});
test("should disconnect public segment for non-app type surveys", async () => {
const mockSegment: TSegment = {
id: "segment1",
title: "Test Segment",
isPrivate: false,
environmentId: "env123",
surveys: ["survey123"],
createdAt: new Date(),
updatedAt: new Date(),
description: null,
filters: [],
};
const updatedSurvey: TSurvey = {
...mockSurvey,
type: "link",
segment: mockSegment,
};
await updateSurvey(updatedSurvey);
expect(prisma.survey.update).toHaveBeenCalledWith({
where: {
id: "survey123",
},
data: {
segment: {
disconnect: true,
},
},
});
});
test("should handle followUps updates", async () => {
const updatedSurvey: TSurvey = {
...mockSurvey,
followUps: [
{
id: "f1",
name: "Existing Follow Up",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey123",
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
action: {
type: "send-email",
properties: {
to: "test@example.com",
subject: "Test",
body: "Test body",
from: "test@formbricks.com",
replyTo: ["reply@formbricks.com"],
attachResponseData: false,
},
},
deleted: false,
},
{
id: "f2",
name: "New Follow Up",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey123",
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
action: {
type: "send-email",
properties: {
to: "new@example.com",
subject: "New Test",
body: "New test body",
from: "test@formbricks.com",
replyTo: ["reply@formbricks.com"],
attachResponseData: false,
},
},
deleted: false,
},
{
id: "f3",
name: "Follow Up To Delete",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey123",
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
action: {
type: "send-email",
properties: {
to: "delete@example.com",
subject: "Delete Test",
body: "Delete test body",
from: "test@formbricks.com",
replyTo: ["reply@formbricks.com"],
attachResponseData: false,
},
},
deleted: true,
},
],
};
// Mock current survey with existing followUps
vi.mocked(getSurvey).mockResolvedValueOnce({
...mockCurrentSurvey,
followUps: [
{
id: "f1",
name: "Existing Follow Up",
trigger: {
type: "response",
properties: {
endingIds: ["ending1"],
},
},
action: {
type: "send-email",
properties: {
to: "test@example.com",
subject: "Test",
body: "Test body",
from: "test@formbricks.com",
replyTo: ["reply@formbricks.com"],
attachResponseData: false,
},
},
},
],
} as any);
await updateSurvey(updatedSurvey);
expect(prisma.survey.update).toHaveBeenCalledWith({
where: { id: "survey123" },
data: expect.objectContaining({
followUps: {
updateMany: [
{
where: {
id: "f1",
},
data: expect.objectContaining({
name: "Existing Follow Up",
}),
},
],
createMany: {
data: [
expect.objectContaining({
name: "New Follow Up",
}),
],
},
deleteMany: [
{
id: "f3",
},
],
},
}),
select: expect.any(Object),
});
});
test("should throw ResourceNotFoundError when survey is not found", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null as unknown as TSurvey);
await expect(updateSurvey(mockSurvey)).rejects.toThrow(ResourceNotFoundError);
expect(getSurvey).toHaveBeenCalledWith("survey123");
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(getOrganizationAIKeys).mockResolvedValueOnce(null);
await expect(updateSurvey(mockSurvey)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError when Prisma throws a known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.update).mockRejectedValueOnce(prismaError);
await expect(updateSurvey(mockSurvey)).rejects.toThrow(DatabaseError);
});
test("should rethrow other errors", async () => {
const genericError = new Error("Some other error");
vi.mocked(prisma.survey.update).mockRejectedValueOnce(genericError);
await expect(updateSurvey(mockSurvey)).rejects.toThrow(genericError);
});
test("should throw InvalidInputError for invalid segment filters", async () => {
const updatedSurvey: TSurvey = {
...mockSurvey,
segment: {
id: "segment1",
title: "Test Segment",
isPrivate: false,
environmentId: "env123",
surveys: ["survey123"],
createdAt: new Date(),
updatedAt: new Date(),
description: null,
filters: "invalid filters" as any,
},
};
await expect(updateSurvey(updatedSurvey)).rejects.toThrow(InvalidInputError);
});
test("should handle error in segment update", async () => {
vi.mocked(prisma.segment.update).mockRejectedValueOnce(new Error("Error updating survey"));
const updatedSurvey: TSurvey = {
...mockSurvey,
segment: {
id: "segment1",
title: "Test Segment",
isPrivate: false,
environmentId: "env123",
surveys: ["survey123"],
createdAt: new Date(),
updatedAt: new Date(),
description: null,
filters: [],
},
};
await expect(updateSurvey(updatedSurvey)).rejects.toThrow("Error updating survey");
});
test("re-exports the shared trigger update helper", () => {
expect(handleTriggerUpdates).toBe(handleTriggerUpdatesFromService);
});
describe("checkTriggersValidity", () => {
const mockActionClasses: ActionClass[] = [
{
id: "action1",
name: "Action 1",
description: "Test Action 1",
type: "code" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
{
id: "action2",
name: "Action 2",
description: "Test Action 2",
type: "noCode" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
];
test("delegates updateSurvey to the shared survey service", async () => {
vi.mocked(updateSurveyFromService).mockResolvedValueOnce(survey);
const createFullActionClass = (id: string, type: "code" | "noCode" = "code"): ActionClass => ({
id,
name: `Action ${id}`,
description: `Test Action ${id}`,
type,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
});
const result = await updateSurvey(survey);
test("should not throw error for valid triggers", () => {
const triggers = [
{ actionClass: createFullActionClass("action1") },
{ actionClass: createFullActionClass("action2", "noCode") },
];
expect(() => checkTriggersValidity(triggers as any, mockActionClasses)).not.toThrow();
});
test("should throw error for invalid trigger id", () => {
const triggers = [
{ actionClass: createFullActionClass("action1") },
{ actionClass: createFullActionClass("invalid") },
];
expect(() => checkTriggersValidity(triggers as any, mockActionClasses)).toThrow(InvalidInputError);
expect(() => checkTriggersValidity(triggers as any, mockActionClasses)).toThrow("Invalid trigger id");
});
test("should throw error for duplicate trigger ids", () => {
const triggers = [
{ actionClass: createFullActionClass("action1") },
{ actionClass: createFullActionClass("action1") },
];
expect(() => checkTriggersValidity(triggers as any, mockActionClasses)).toThrow(InvalidInputError);
expect(() => checkTriggersValidity(triggers as any, mockActionClasses)).toThrow("Duplicate trigger id");
});
test("should do nothing when triggers are undefined", () => {
expect(() => checkTriggersValidity(undefined as any, mockActionClasses)).not.toThrow();
});
expect(updateSurveyFromService).toHaveBeenCalledWith(survey);
expect(result).toBe(survey);
});
describe("handleTriggerUpdates", () => {
const mockActionClasses: ActionClass[] = [
{
id: "action1",
name: "Action 1",
description: "Test Action 1",
type: "code" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
{
id: "action2",
name: "Action 2",
description: "Test Action 2",
type: "noCode" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
{
id: "action3",
name: "Action 3",
description: "Test Action 3",
type: "noCode" as const,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
},
];
test("delegates draft saves to updateSurveyInternal with skipValidation enabled", async () => {
vi.mocked(updateSurveyInternal).mockResolvedValueOnce(survey);
const createActionClassObj = (id: string, type: "code" | "noCode" = "code"): ActionClass => ({
id,
name: `Action ${id}`,
description: `Test Action ${id}`,
type,
environmentId: "env123",
createdAt: new Date(),
updatedAt: new Date(),
key: null,
noCodeConfig: null,
});
const result = await updateSurveyDraft(survey);
test("should return empty object when updatedTriggers is undefined", () => {
const result = handleTriggerUpdates(undefined as any, [], mockActionClasses);
expect(result).toEqual({});
});
test("should identify added triggers correctly", () => {
const currentTriggers = [{ actionClass: createActionClassObj("action1") }];
const updatedTriggers = [
{ actionClass: createActionClassObj("action1") },
{ actionClass: createActionClassObj("action2", "noCode") },
];
const result = handleTriggerUpdates(updatedTriggers as any, currentTriggers as any, mockActionClasses);
expect(result).toEqual({
create: [{ actionClassId: "action2" }],
});
});
test("should identify deleted triggers correctly", () => {
const currentTriggers = [
{ actionClass: createActionClassObj("action1") },
{ actionClass: createActionClassObj("action2", "noCode") },
];
const updatedTriggers = [{ actionClass: createActionClassObj("action1") }];
const result = handleTriggerUpdates(updatedTriggers as any, currentTriggers as any, mockActionClasses);
expect(result).toEqual({
deleteMany: {
actionClassId: {
in: ["action2"],
},
},
});
});
test("should handle both added and deleted triggers", () => {
const currentTriggers = [
{ actionClass: createActionClassObj("action1") },
{ actionClass: createActionClassObj("action2", "noCode") },
];
const updatedTriggers = [
{ actionClass: createActionClassObj("action1") },
{ actionClass: createActionClassObj("action3", "noCode") },
];
const result = handleTriggerUpdates(updatedTriggers as any, currentTriggers as any, mockActionClasses);
expect(result).toEqual({
create: [{ actionClassId: "action3" }],
deleteMany: {
actionClassId: {
in: ["action2"],
},
},
});
});
test("should validate triggers before processing", () => {
const currentTriggers = [{ actionClass: createActionClassObj("action1") }];
const updatedTriggers = [
{ actionClass: createActionClassObj("action1") },
{ actionClass: createActionClassObj("invalid") },
];
expect(() =>
handleTriggerUpdates(updatedTriggers as any, currentTriggers as any, mockActionClasses)
).toThrow(InvalidInputError);
});
expect(updateSurveyInternal).toHaveBeenCalledWith(survey, true);
expect(result).toBe(survey);
});
describe("updateSurveyDraft", () => {
const mockSurvey = {
id: "survey123",
createdAt: new Date(),
updatedAt: new Date(),
name: "Draft Survey",
type: "app",
environmentId: "env123",
createdBy: "user123",
status: "draft",
displayOption: "displayOnce",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
],
welcomeCard: {
enabled: false,
timeToFinish: true,
showResponseCount: false,
},
triggers: [],
endings: [],
hiddenFields: { enabled: false },
delay: 0,
autoComplete: null,
projectOverwrites: null,
styling: null,
showLanguageSwitch: false,
segment: null,
surveyClosedMessage: null,
singleUse: null,
isVerifyEmailEnabled: false,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
pin: null,
displayPercentage: null,
languages: [],
variables: [],
followUps: [],
} as unknown as TSurvey;
test("propagates service errors for updateSurvey", async () => {
const error = new DatabaseError("database error");
vi.mocked(updateSurveyFromService).mockRejectedValueOnce(error);
beforeEach(() => {
vi.mocked(updateSurveyInternal).mockResolvedValue(mockSurvey);
});
await expect(updateSurvey(survey)).rejects.toThrow(error);
});
test("should call updateSurveyInternal with skipValidation=true", async () => {
await updateSurveyDraft(mockSurvey);
test("propagates service errors for updateSurveyDraft", async () => {
const error = new ResourceNotFoundError("Survey", "survey_1");
vi.mocked(updateSurveyInternal).mockRejectedValueOnce(error);
expect(updateSurveyInternal).toHaveBeenCalledWith(mockSurvey, true);
expect(updateSurveyInternal).toHaveBeenCalledTimes(1);
});
test("should return the survey from updateSurveyInternal", async () => {
const result = await updateSurveyDraft(mockSurvey);
expect(result).toEqual(mockSurvey);
});
test("should propagate errors from updateSurveyInternal", async () => {
const error = new Error("Internal update failed");
vi.mocked(updateSurveyInternal).mockRejectedValueOnce(error);
await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow("Internal update failed");
});
test("should propagate ResourceNotFoundError from updateSurveyInternal", async () => {
vi.mocked(updateSurveyInternal).mockRejectedValueOnce(new ResourceNotFoundError("Survey", "survey123"));
await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow(ResourceNotFoundError);
});
test("should propagate DatabaseError from updateSurveyInternal", async () => {
vi.mocked(updateSurveyInternal).mockRejectedValueOnce(new DatabaseError("Database connection failed"));
await expect(updateSurveyDraft(mockSurvey)).rejects.toThrow(DatabaseError);
});
await expect(updateSurveyDraft(survey)).rejects.toThrow(error);
});
});
+8 -349
View File
@@ -1,357 +1,16 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { updateSurveyInternal } from "@/lib/survey/service";
import { validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getSurvey, selectSurvey } from "@/modules/survey/lib/survey";
import {
handleTriggerUpdates,
updateSurvey as updateSurveyFromService,
updateSurveyInternal,
} from "@/lib/survey/service";
export { handleTriggerUpdates };
export const updateSurveyDraft = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
// Use internal version with skipValidation=true to allow incomplete drafts
return updateSurveyInternal(updatedSurvey, true);
};
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
try {
const surveyId = updatedSurvey.id;
let data: any = {};
const actionClasses = await getActionClasses(updatedSurvey.environmentId);
const currentSurvey = await getSurvey(surveyId);
if (!currentSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
// Validate and prepare blocks for persistence
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(updatedSurvey.blocks);
}
if (languages) {
// Process languages update logic here
// Extract currentLanguageIds and updatedLanguageIds
const currentLanguageIds = currentSurvey.languages
? currentSurvey.languages.map((l) => l.language.id)
: [];
const updatedLanguageIds =
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
const enabledLanguageIds = languages.map((language) => {
if (language.enabled) return language.language.id;
});
// Determine languages to add and remove
const languagesToAdd = updatedLanguageIds.filter((id) => !currentLanguageIds.includes(id));
const languagesToRemove = currentLanguageIds.filter((id) => !updatedLanguageIds.includes(id));
const defaultLanguageId = updatedSurvey.languages.find((l) => l.default)?.language.id;
// Prepare data for Prisma update
data.languages = {};
// Update existing languages for default value changes
data.languages.updateMany = currentSurvey.languages.map((surveyLanguage) => ({
where: { languageId: surveyLanguage.language.id },
data: {
default: surveyLanguage.language.id === defaultLanguageId,
enabled: enabledLanguageIds.includes(surveyLanguage.language.id),
},
}));
// Add new languages
if (languagesToAdd.length > 0) {
data.languages.create = languagesToAdd.map((languageId) => ({
languageId: languageId,
default: languageId === defaultLanguageId,
enabled: enabledLanguageIds.includes(languageId),
}));
}
// Remove languages no longer associated with the survey
if (languagesToRemove.length > 0) {
data.languages.deleteMany = languagesToRemove.map((languageId) => ({
languageId: languageId,
enabled: enabledLanguageIds.includes(languageId),
}));
}
}
if (triggers) {
data.triggers = handleTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
}
// if the survey body has type other than "app" but has a private segment, we delete that segment, and if it has a public segment, we disconnect from to the survey
if (segment) {
if (type === "app") {
// parse the segment filters:
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {
throw new InvalidInputError("Invalid user segment filters");
}
try {
// update the segment:
let updatedInput: Prisma.SegmentUpdateInput = {
...segment,
surveys: undefined,
};
if (segment.surveys) {
updatedInput = {
...segment,
surveys: {
connect: segment.surveys.map((surveyId) => ({ id: surveyId })),
},
};
}
await prisma.segment.update({
where: { id: segment.id },
data: updatedInput,
select: {
surveys: { select: { id: true } },
environmentId: true,
id: true,
},
});
} catch (error) {
logger.error(error, "Error updating survey");
throw new Error("Error updating survey");
}
} else {
if (segment.isPrivate) {
// disconnect the private segment first and then delete:
await prisma.segment.update({
where: { id: segment.id },
data: {
surveys: {
disconnect: {
id: surveyId,
},
},
},
});
// delete the private segment:
await prisma.segment.delete({
where: {
id: segment.id,
},
});
} else {
await prisma.survey.update({
where: {
id: surveyId,
},
data: {
segment: {
disconnect: true,
},
},
});
}
}
} else if (type === "app") {
if (!currentSurvey.segment) {
await prisma.survey.update({
where: {
id: surveyId,
},
data: {
segment: {
connectOrCreate: {
where: {
environmentId_title: {
environmentId,
title: surveyId,
},
},
create: {
title: surveyId,
isPrivate: true,
filters: [],
environment: {
connect: {
id: environmentId,
},
},
},
},
},
},
});
}
}
if (followUps) {
// Separate follow-ups into categories based on deletion flag
const deletedFollowUps = followUps.filter((followUp) => followUp.deleted);
const nonDeletedFollowUps = followUps.filter((followUp) => !followUp.deleted);
// Get set of existing follow-up IDs from currentSurvey
const existingFollowUpIds = new Set(currentSurvey.followUps.map((f) => f.id));
// Separate non-deleted follow-ups into new and existing
const existingFollowUps = nonDeletedFollowUps.filter((followUp) =>
existingFollowUpIds.has(followUp.id)
);
const newFollowUps = nonDeletedFollowUps.filter((followUp) => !existingFollowUpIds.has(followUp.id));
data.followUps = {
// Update existing follow-ups
updateMany: existingFollowUps.map((followUp) => ({
where: {
id: followUp.id,
},
data: {
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
},
})),
// Create new follow-ups
createMany:
newFollowUps.length > 0
? {
data: newFollowUps.map((followUp) => ({
id: followUp.id,
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
}
: undefined,
// Delete follow-ups marked as deleted, regardless of whether they exist in DB
deleteMany:
deletedFollowUps.length > 0
? deletedFollowUps.map((followUp) => ({
id: followUp.id,
}))
: undefined,
};
}
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const organization = await getOrganizationAIKeys(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
}
surveyData.updatedAt = new Date();
data = {
...surveyData,
...data,
type,
};
delete data.createdBy;
const prismaSurvey = await prisma.survey.update({
where: { id: surveyId },
data,
select: selectSurvey,
});
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error updating survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
const getTriggerIds = (triggers: unknown): string[] | null => {
if (!triggers) return null;
if (!Array.isArray(triggers)) {
throw new InvalidInputError("Invalid trigger id");
}
return triggers.map((trigger) => {
const actionClassId = (trigger as { actionClass?: { id?: unknown } })?.actionClass?.id;
if (typeof actionClassId !== "string") {
throw new InvalidInputError("Invalid trigger id");
}
return actionClassId;
});
};
export const checkTriggersValidity = (triggers: unknown, actionClasses: Array<{ id: string }>) => {
const triggerIds = getTriggerIds(triggers);
if (!triggerIds) return;
// check if all the triggers are valid
triggerIds.forEach((triggerId) => {
if (!actionClasses.find((actionClass) => actionClass.id === triggerId)) {
throw new InvalidInputError("Invalid trigger id");
}
});
if (new Set(triggerIds).size !== triggerIds.length) {
throw new InvalidInputError("Duplicate trigger id");
}
};
export const handleTriggerUpdates = (
updatedTriggers: unknown,
currentTriggers: unknown,
actionClasses: Array<{ id: string }>
) => {
const updatedTriggerIds = getTriggerIds(updatedTriggers);
if (!updatedTriggerIds) return {};
checkTriggersValidity(updatedTriggers, actionClasses);
const currentTriggerIds = getTriggerIds(currentTriggers) ?? [];
// added triggers are triggers that are not in the current triggers and are there in the new triggers
const addedTriggerIds = updatedTriggerIds.filter((triggerId) => !currentTriggerIds.includes(triggerId));
// deleted triggers are triggers that are not in the new triggers and are there in the current triggers
const deletedTriggerIds = currentTriggerIds.filter((triggerId) => !updatedTriggerIds.includes(triggerId));
// Construct the triggers update object
const triggersUpdate: TriggerUpdate = {};
if (addedTriggerIds.length > 0) {
triggersUpdate.create = addedTriggerIds.map((triggerId) => ({
actionClassId: triggerId,
}));
}
if (deletedTriggerIds.length > 0) {
// disconnect the public triggers from the survey
triggersUpdate.deleteMany = {
actionClassId: {
in: deletedTriggerIds,
},
};
}
return triggersUpdate;
return updateSurveyFromService(updatedSurvey);
};
+3 -2
View File
@@ -1,3 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
DEFAULT_LOCALE,
IS_FORMBRICKS_CLOUD,
@@ -60,12 +61,12 @@ export const SurveyEditorPage = async (props: {
]);
if (!projectWithTeamIds) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const organizationBilling = await getOrganizationBilling(projectWithTeamIds.organizationId);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
throw new ResourceNotFoundError(t("common.organization"), projectWithTeamIds.organizationId);
}
const isSurveyCreationDeletionDisabled = isMember && hasReadAccess;
+2
View File
@@ -16,6 +16,8 @@ export const selectSurvey = {
environmentId: true,
createdBy: true,
status: true,
startsAt: true,
endsAt: true,
welcomeCard: true,
questions: true,
blocks: true,
@@ -7,6 +7,8 @@ export const surveySelect = {
updatedAt: true,
name: true,
type: true,
startsAt: true,
endsAt: true,
creator: {
select: {
name: true,
@@ -6,6 +6,7 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TActionClassType } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { publishSurveyLifecycleCancellationEvents } from "@/lib/inngest/survey-lifecycle";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { checkForInvalidMediaInBlocks } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
@@ -37,6 +38,10 @@ vi.mock("@/lib/survey/utils", () => ({
checkForInvalidMediaInBlocks: vi.fn(() => ({ ok: true, data: undefined })),
}));
vi.mock("@/lib/inngest/survey-lifecycle", () => ({
publishSurveyLifecycleCancellationEvents: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
@@ -76,6 +81,7 @@ vi.mock("@/lingodotdev/server", () => ({
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
survey: {
findMany: vi.fn(),
findUnique: vi.fn(),
@@ -126,9 +132,11 @@ const resetMocks = () => {
vi.mocked(prisma.survey.count).mockReset();
vi.mocked(prisma.survey.delete).mockReset();
vi.mocked(prisma.survey.create).mockReset();
vi.mocked(prisma.$transaction).mockReset();
vi.mocked(prisma.segment.delete).mockReset();
vi.mocked(prisma.segment.findFirst).mockReset();
vi.mocked(prisma.actionClass.findMany).mockReset();
vi.mocked(publishSurveyLifecycleCancellationEvents).mockReset();
vi.mocked(logger.error).mockClear();
};
@@ -423,6 +431,9 @@ describe("getSurveysSortedByRelevance", () => {
describe("deleteSurvey", () => {
beforeEach(() => {
resetMocks();
vi.mocked(prisma.$transaction).mockImplementation(
async (callback: (tx: typeof prisma) => Promise<unknown>) => callback(prisma)
);
});
const mockDeletedSurveyData = {
@@ -442,6 +453,10 @@ describe("deleteSurvey", () => {
where: { id: surveyId },
select: expect.objectContaining({ id: true, environmentId: true, segment: expect.anything() }),
});
expect(publishSurveyLifecycleCancellationEvents).toHaveBeenCalledWith({
surveyId,
environmentId,
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
+36 -26
View File
@@ -7,6 +7,7 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
import { publishSurveyLifecycleCancellationEvents } from "@/lib/inngest/survey-lifecycle";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { checkForInvalidMediaInBlocks } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
@@ -147,39 +148,48 @@ export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey |
export const deleteSurvey = async (surveyId: string): Promise<boolean> => {
try {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
select: {
id: true,
environmentId: true,
segment: {
select: {
id: true,
isPrivate: true,
},
const deletedSurvey = await prisma.$transaction(async (tx) => {
const removedSurvey = await tx.survey.delete({
where: {
id: surveyId,
},
type: true,
triggers: {
select: {
actionClass: {
select: {
id: true,
select: {
id: true,
environmentId: true,
segment: {
select: {
id: true,
isPrivate: true,
},
},
type: true,
triggers: {
select: {
actionClass: {
select: {
id: true,
},
},
},
},
},
},
});
if (removedSurvey.type === "app" && removedSurvey.segment?.isPrivate) {
await tx.segment.delete({
where: {
id: removedSurvey.segment.id,
},
});
}
return removedSurvey;
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
await publishSurveyLifecycleCancellationEvents({
surveyId: deletedSurvey.id,
environmentId: deletedSurvey.environmentId,
});
return true;
} catch (error) {
+2 -1
View File
@@ -2,6 +2,7 @@ import { PlusIcon } from "lucide-react";
import { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { DEFAULT_LOCALE, SURVEYS_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserLocale } from "@/lib/user/service";
@@ -33,7 +34,7 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
const project = await getProjectWithTeamIdsByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const { session, isBilling, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
@@ -8,6 +8,8 @@ export const ZSurvey = z.object({
environmentId: z.string(),
type: z.enum(["link", "app", "website", "web"]), //we can replace this with ZSurveyType after we remove "web" from schema
status: ZSurveyStatus,
startsAt: z.date().nullable().optional(),
endsAt: z.date().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
responseCount: z.number(),
+2 -1
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -21,7 +22,7 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
const project = await getProjectWithTeamIdsByEnvironmentId(environmentId);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), null);
}
if (isReadOnly) {
@@ -2,7 +2,7 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Column, Table, flexRender } from "@tanstack/react-table";
import { Column, HeaderContext, Table, flexRender } from "@tanstack/react-table";
import { GripVertical } from "lucide-react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { Switch } from "@/modules/ui/components/switch";
@@ -24,11 +24,10 @@ export const DataTableSettingsModalItem = <T,>({ column, table }: DataTableSetti
zIndex: isDragging ? 10 : 1,
};
// Find the header for this column from the table's header groups
const header = table
.getHeaderGroups()
.flatMap((headerGroup) => headerGroup.headers)
.find((h) => h.column.id === column.id);
// Build a minimal header context so we can render the column's header definition regardless of
// whether the column is currently visible. getHeaderGroups() only includes visible columns, so
// hidden columns would fall back to rendering the raw column ID without this approach.
const headerContext = { column, header: null, table } as unknown as HeaderContext<any, unknown>;
return (
<div ref={setNodeRef} style={style} id={column.id}>
@@ -40,7 +39,7 @@ export const DataTableSettingsModalItem = <T,>({ column, table }: DataTableSetti
<button type="button" aria-label="Reorder column" onClick={(e) => e.preventDefault()}>
<GripVertical className="h-4 w-4" />
</button>
{header ? flexRender(column.columnDef.header, header.getContext()) : column.id}
{flexRender(column.columnDef.header, headerContext)}
</div>
<Switch
id={column.id}
@@ -13,7 +13,6 @@ import { Table } from "@tanstack/react-table";
import { SettingsIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
Dialog,
DialogBody,
@@ -30,7 +29,6 @@ interface DataTableSettingsModalProps<T> {
table: Table<T>;
columnOrder: string[];
handleDragEnd: (event: DragEndEvent) => void;
survey?: TSurvey;
}
export const DataTableSettingsModal = <T,>({
@@ -39,7 +37,6 @@ export const DataTableSettingsModal = <T,>({
table,
columnOrder,
handleDragEnd,
survey,
}: DataTableSettingsModalProps<T>) => {
const { t } = useTranslation();
const sensors = useSensors(
@@ -72,9 +69,7 @@ export const DataTableSettingsModal = <T,>({
if (columnId === "select" || columnId === "createdAt") return;
const column = tableColumns.find((column) => column.id === columnId);
if (!column) return null;
return (
<DataTableSettingsModalItem column={column} table={table} key={column.id} survey={survey} />
);
return <DataTableSettingsModalItem column={column} table={table} key={column.id} />;
})}
</SortableContext>
</DndContext>
@@ -1,4 +1,5 @@
import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { WEBAPP_URL } from "@/lib/constants";
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -13,7 +14,7 @@ export const EnvironmentNotice = async ({ environmentId, subPageUrl }: Environme
const [t, environment] = await Promise.all([getTranslate(), getEnvironment(environmentId)]);
if (!environment) {
throw new Error("Environment not found");
throw new ResourceNotFoundError(t("common.environment"), environmentId);
}
const environments = await getEnvironments(environment.projectId);
@@ -22,7 +23,7 @@ export const EnvironmentNotice = async ({ environmentId, subPageUrl }: Environme
);
if (!otherEnvironment) {
throw new Error("Other environment not found");
throw new ResourceNotFoundError(t("common.environment"), null);
}
const currentEnvironmentLabel = t(
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { ArrowUpFromLineIcon } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { cn } from "@/lib/cn";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
@@ -310,7 +310,11 @@ export const PreviewSurvey = ({
setIsFullScreenPreview(true);
}
}}
aria-label={isFullScreenPreview ? t("environments.surveys.edit.shrink_preview") : t("environments.surveys.edit.expand_preview")}></button>
aria-label={
isFullScreenPreview
? t("environments.surveys.edit.shrink_preview")
: t("environments.surveys.edit.expand_preview")
}></button>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>
+1
View File
@@ -89,6 +89,7 @@
"i18next": "25.8.18",
"i18next-icu": "2.4.3",
"i18next-resources-to-backend": "1.2.1",
"inngest": "4.0.5",
"jiti": "2.6.1",
"jsonwebtoken": "9.0.3",
"lexical": "0.41.0",
+55
View File
@@ -9,6 +9,11 @@ services:
- POSTGRES_PASSWORD=postgres
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres || exit 1"]
interval: 5s
timeout: 5s
retries: 5
mailhog:
image: arjenz/mailhog
@@ -23,6 +28,11 @@ services:
- 6379:6379
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
@@ -36,6 +46,51 @@ services:
volumes:
- minio-data:/data
inngest:
profiles: ["inngest-poc"]
image: inngest/inngest
command: "inngest start"
ports:
- 8288:8288
- 8289:8289
environment:
- INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY:?INNGEST_EVENT_KEY is required}
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY:?INNGEST_SIGNING_KEY is required}
- INNGEST_POSTGRES_URI=postgres://postgres:postgres@postgres:5432/postgres
- INNGEST_REDIS_URI=redis://valkey:6379
- INNGEST_SDK_URL=http://inngest-poc-worker:8287/api/inngest
- INNGEST_POLL_INTERVAL=60
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
inngest-poc-worker:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8288/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
inngest-poc-worker:
profiles: ["inngest-poc"]
build:
context: .
dockerfile: services/inngest-poc-worker/Dockerfile
environment:
- INNGEST_BASE_URL=http://inngest:8288
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY:?INNGEST_SIGNING_KEY is required}
- PORT=8287
ports:
- 8287:8287
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8287/health"]
interval: 10s
timeout: 3s
retries: 5
volumes:
postgres:
driver: local
+6
View File
@@ -27,3 +27,9 @@ The script will prompt you for the following information:
3. **Domain Name**: Enter the domain name that Traefik will use to create the SSL certificate and forward requests to Formbricks.
That's it! After running the command and providing the required information, visit the domain name you entered, and you should see the Formbricks home wizard!
## Optional Inngest POC Profile
The experimental `inngest-poc` Docker Compose profile adds a self-hosted Inngest server and the Go worker used for the survey start/end lifecycle proof of concept. It reuses the same Postgres and Redis services that already back Formbricks in this stack.
Because the worker is built from source, this profile is meant to be used from a full checkout of the Formbricks repository rather than the one-file quickstart flow above.
+53
View File
@@ -199,6 +199,14 @@ x-environment: &environment
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE: 86400
########################################## OPTIONAL (INNGEST POC PROFILE) ##########################################
# Only required when starting docker compose with --profile inngest-poc
# Replace both values with your own `openssl rand -hex 32` output before exposing this profile.
INNGEST_BASE_URL: http://inngest:8288
INNGEST_EVENT_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
INNGEST_SIGNING_KEY: abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789
services:
postgres:
restart: always
@@ -245,6 +253,51 @@ services:
- ./saml-connection:/home/nextjs/apps/web/saml-connection
<<: *environment
inngest:
profiles: ["inngest-poc"]
restart: always
image: inngest/inngest
command: "inngest start"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
inngest-poc-worker:
condition: service_started
ports:
- 8288:8288
- 8289:8289
environment:
- INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY:?INNGEST_EVENT_KEY is required}
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY:?INNGEST_SIGNING_KEY is required}
- INNGEST_POSTGRES_URI=postgres://postgres:postgres@postgres:5432/postgres
- INNGEST_REDIS_URI=redis://redis:6379
- INNGEST_SDK_URL=http://inngest-poc-worker:8287/api/inngest
- INNGEST_POLL_INTERVAL=60
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8288/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
inngest-poc-worker:
profiles: ["inngest-poc"]
restart: always
build:
context: ..
dockerfile: services/inngest-poc-worker/Dockerfile
environment:
- INNGEST_BASE_URL=http://inngest:8288
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY:?INNGEST_SIGNING_KEY is required}
- PORT=8287
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8287/health"]
interval: 10s
timeout: 3s
retries: 5
volumes:
postgres:
driver: local
@@ -32,21 +32,13 @@ icon: "github"
pnpm install
```
4. **Create a `.env` file from the template:**
4. **Create a development `.env` file and generate the required secrets:**
```bash
cp .env.example .env
pnpm dev:setup
```
5. **Generate & set the required secrets:**
```bash
sed -i '/^ENCRYPTION_KEY=/c\ENCRYPTION_KEY='$(openssl rand -hex 32) .env
sed -i '/^NEXTAUTH_SECRET=/c\NEXTAUTH_SECRET='$(openssl rand -hex 32) .env
sed -i '/^CRON_SECRET=/c\CRON_SECRET='$(openssl rand -hex 32) .env
```
6. **Generate the Next.js AGENTS.md file (optional, for AI-assisted development):**
5. **Generate the Next.js AGENTS.md file (optional, for AI-assisted development):**
This step generates an `AGENTS.md` file at the repository root that provides Next.js documentation context for AI coding assistants (e.g. Cursor, GitHub Copilot). It runs `npx @next/codemod agents-md` under the hood. Re-run it whenever you upgrade Next.js.
@@ -54,7 +46,7 @@ icon: "github"
pnpm agents:update
```
7. **Launch the development setup:**
6. **Launch the development setup:**
```bash
pnpm go
```
+4 -12
View File
@@ -32,21 +32,13 @@ icon: "code"
pnpm install
```
4. **Create a `.env` file:**
4. **Create a development `.env` file and generate the required secrets:**
```bash
cp .env.example .env
pnpm dev:setup
```
5. **Generate & set secret values:**
```bash
sed -i '/^ENCRYPTION_KEY=/c\ENCRYPTION_KEY='$(openssl rand -hex 32) .env
sed -i '/^NEXTAUTH_SECRET=/c\NEXTAUTH_SECRET='$(openssl rand -hex 32) .env
sed -i '/^CRON_SECRET=/c\CRON_SECRET='$(openssl rand -hex 32) .env
```
6. **Generate the Next.js AGENTS.md file (optional, for AI-assisted development):**
5. **Generate the Next.js AGENTS.md file (optional, for AI-assisted development):**
This step generates an `AGENTS.md` file at the repository root that provides Next.js documentation context for AI coding assistants (e.g. Cursor, GitHub Copilot). It runs `npx @next/codemod agents-md` under the hood. Re-run it whenever you upgrade Next.js.
@@ -54,7 +46,7 @@ icon: "code"
pnpm agents:update
```
7. **Run the development setup:**
6. **Run the development setup:**
```bash
pnpm go
```

Some files were not shown because too many files have changed in this diff Show More