mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 02:46:46 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dedf552001 | |||
| fca5a808fb | |||
| 57a5c3ce76 | |||
| 71cca557fc | |||
| 1500b6f7f3 | |||
| 2c9fbf83e4 | |||
| 59cc9c564e | |||
| 20dc147682 | |||
| 2bb7a6f277 | |||
| deb062dd03 | |||
| 474be86d33 | |||
| e7ca66ed77 | |||
| 2b49dbecd3 | |||
| 6da4c6f352 | |||
| 659b240fca | |||
| 19c0b1d14d | |||
| b4472f48e9 |
@@ -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 workspace = await getWorkspaceByEnvironmentId(environment.id);
|
||||
if (!workspace) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const channel = workspace.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 { getUser } from "@/lib/user/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 workspace = await getWorkspaceByEnvironmentId(environment.id);
|
||||
if (!workspace) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const workspaces = await getUserWorkspaces(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 WorkspaceOnboardingLayout = 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 WorkspaceOnboardingLayout = 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 (
|
||||
|
||||
+2
-1
@@ -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 [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
TWorkspaceConfigChannel,
|
||||
TWorkspaceConfigIndustry,
|
||||
@@ -49,7 +50,7 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
|
||||
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 { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -46,7 +50,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(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 workspace permission exists for members
|
||||
if (isMember && !workspacePermission) {
|
||||
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 { getWorkspaceByEnvironmentId } from "@/lib/workspace/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 (!workspace) {
|
||||
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}</>;
|
||||
|
||||
+4
-3
@@ -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";
|
||||
|
||||
+2
-1
@@ -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 { getWorkspaceByEnvironmentId } from "@/lib/workspace/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 (!workspace) {
|
||||
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
@@ -300,7 +300,6 @@ export const ResponseTable = ({
|
||||
<DataTableSettingsModal
|
||||
open={isTableSettingsModalOpen}
|
||||
setOpen={setIsTableSettingsModalOpen}
|
||||
survey={survey}
|
||||
table={table}
|
||||
columnOrder={columnOrder}
|
||||
handleDragEnd={handleDragEnd}
|
||||
|
||||
+5
-4
@@ -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);
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getStyling } from "@/lib/utils/styling";
|
||||
@@ -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 workspace = await getWorkspaceByEnvironmentId(survey.environmentId);
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found");
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const styling = getStyling(workspace, survey);
|
||||
|
||||
+54
-65
@@ -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)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+24
-112
@@ -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++) {
|
||||
|
||||
+5
-4
@@ -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);
|
||||
|
||||
|
||||
+26
-1
@@ -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>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createDisplay } from "./lib/display";
|
||||
|
||||
@@ -21,10 +22,20 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
|
||||
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
|
||||
const resolved = await resolveClientApiIds(params.environmentId);
|
||||
if (!resolved) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Environment", params.environmentId),
|
||||
};
|
||||
}
|
||||
const { environmentId } = resolved;
|
||||
|
||||
const jsonInput = await req.json();
|
||||
const inputValidation = ZDisplayCreateInput.safeParse({
|
||||
...jsonInput,
|
||||
environmentId: params.environmentId,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
@@ -38,7 +49,7 @@ export const POST = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
if (inputValidation.data.userId) {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse(
|
||||
@@ -29,15 +30,10 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const environmentId = params.environmentId.trim();
|
||||
const idParam = params.environmentId.trim();
|
||||
|
||||
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
|
||||
// This catches all invalid formats including:
|
||||
// - null/undefined passed as string "null" or "undefined"
|
||||
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
|
||||
// - Empty or whitespace-only IDs
|
||||
// - Any other invalid CUID v1 format
|
||||
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
// Validate CUID format
|
||||
const cuidValidation = ZEnvironmentId.safeParse(idParam);
|
||||
if (!cuidValidation.success) {
|
||||
logger.warn(
|
||||
{
|
||||
@@ -45,13 +41,23 @@ export const GET = withV1ApiWrapper({
|
||||
url: req.url,
|
||||
validationError: cuidValidation.error.issues[0]?.message,
|
||||
},
|
||||
"Invalid CUID v1 format detected"
|
||||
"Invalid CUID format detected"
|
||||
);
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
|
||||
const resolved = await resolveClientApiIds(idParam);
|
||||
if (!resolved) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Environment", idParam),
|
||||
};
|
||||
}
|
||||
|
||||
const { environmentId } = resolved;
|
||||
|
||||
// Use optimized environment state fetcher with new caching approach
|
||||
const environmentState = await getEnvironmentState(environmentId);
|
||||
const { data } = environmentState;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { headers } from "next/headers";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
@@ -13,6 +12,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
@@ -66,19 +66,16 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
|
||||
const resolved = await resolveClientApiIds(params.environmentId);
|
||||
if (!resolved) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
),
|
||||
response: responses.notFoundResponse("Environment", params.environmentId),
|
||||
};
|
||||
}
|
||||
const { environmentId } = resolved;
|
||||
|
||||
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!responseInputValidation.success) {
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging
|
||||
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSignedUrlForUpload } from "@/modules/storage/service";
|
||||
@@ -29,7 +30,16 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
const { environmentId } = params;
|
||||
|
||||
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
|
||||
const resolved = await resolveClientApiIds(params.environmentId);
|
||||
if (!resolved) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Environment", params.environmentId),
|
||||
};
|
||||
}
|
||||
const { environmentId } = resolved;
|
||||
|
||||
let jsonInput: TUploadPrivateFileRequest;
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displ
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createDisplay } from "./lib/display";
|
||||
|
||||
@@ -25,10 +26,18 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
|
||||
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
|
||||
const resolved = await resolveClientApiIds(params.environmentId);
|
||||
if (!resolved) {
|
||||
return responses.notFoundResponse("Environment", params.environmentId);
|
||||
}
|
||||
const { environmentId } = resolved;
|
||||
|
||||
const jsonInput = await request.json();
|
||||
const inputValidation = ZDisplayCreateInputV2.safeParse({
|
||||
...jsonInput,
|
||||
environmentId: params.environmentId,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
@@ -40,7 +49,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
}
|
||||
|
||||
if (inputValidation.data.contactId) {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { headers } from "next/headers";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||
@@ -12,6 +11,7 @@ import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -49,17 +49,14 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
);
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
);
|
||||
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
|
||||
const resolved = await resolveClientApiIds(params.environmentId);
|
||||
if (!resolved) {
|
||||
return responses.notFoundResponse("Environment", params.environmentId);
|
||||
}
|
||||
const { environmentId } = resolved;
|
||||
|
||||
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!responseInputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
|
||||
+6
-9
@@ -142,6 +142,7 @@ checksums:
|
||||
common/connect: 8778ee245078a8be4a2ce855c8c56edc
|
||||
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
|
||||
common/connected: aa0ceca574641de34c74b9e590664230
|
||||
common/contact: 9afa39bc47019ee6dec6c74b6273967c
|
||||
common/contacts: d5b6c3f890b3904eaf5754081945c03d
|
||||
common/continue: 3cfba90b4600131e82fc4260c568d044
|
||||
common/copied: 29208e06d704c4fc4b8b534dc7acc4ef
|
||||
@@ -189,12 +190,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
|
||||
@@ -258,7 +259,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
|
||||
@@ -298,10 +301,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
|
||||
@@ -393,7 +395,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
|
||||
@@ -408,7 +409,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
|
||||
@@ -432,7 +432,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
|
||||
@@ -448,14 +447,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
|
||||
@@ -631,7 +629,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { resolveClientApiIds } from "./resolve-client-id";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
environment: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
workspace: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("resolveClientApiIds", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resolves an environmentId to environmentId + workspaceId", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
|
||||
id: "env-123",
|
||||
workspaceId: "ws-456",
|
||||
} as any);
|
||||
|
||||
const result = await resolveClientApiIds("env-123");
|
||||
|
||||
expect(result).toEqual({
|
||||
environmentId: "env-123",
|
||||
workspaceId: "ws-456",
|
||||
});
|
||||
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "env-123" },
|
||||
select: { id: true, workspaceId: true },
|
||||
});
|
||||
expect(prisma.workspace.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves a workspaceId to workspaceId + production environmentId", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.workspace.findUnique).mockResolvedValue({
|
||||
id: "ws-456",
|
||||
environments: [{ id: "env-prod-789" }],
|
||||
} as any);
|
||||
|
||||
const result = await resolveClientApiIds("ws-456");
|
||||
|
||||
expect(result).toEqual({
|
||||
environmentId: "env-prod-789",
|
||||
workspaceId: "ws-456",
|
||||
});
|
||||
expect(prisma.workspace.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "ws-456" },
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
where: { type: "production" },
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when neither environment nor workspace is found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.workspace.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await resolveClientApiIds("unknown-id");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when workspace exists but has no production environment", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.workspace.findUnique).mockResolvedValue({
|
||||
id: "ws-456",
|
||||
environments: [],
|
||||
} as any);
|
||||
|
||||
const result = await resolveClientApiIds("ws-456");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import "server-only";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export type TResolvedClientIds = {
|
||||
workspaceId: string;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a URL parameter that may be an environmentId (old SDK) or workspaceId (new SDK).
|
||||
*
|
||||
* - If the id matches an Environment, returns the environment's id and its parent workspaceId.
|
||||
* - If not, checks the Workspace table and returns the workspace's production environment id.
|
||||
* - Returns null if neither lookup succeeds.
|
||||
*/
|
||||
export const resolveClientApiIds = async (id: string): Promise<TResolvedClientIds | null> => {
|
||||
// Try as environmentId first (existing SDKs)
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, workspaceId: true },
|
||||
});
|
||||
|
||||
if (environment) {
|
||||
return { workspaceId: environment.workspaceId, environmentId: environment.id };
|
||||
}
|
||||
|
||||
// Try as workspaceId (new SDKs sending workspaceId)
|
||||
const workspace = await prisma.workspace.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
where: { type: "production" },
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (workspace && workspace.environments[0]) {
|
||||
return { workspaceId: workspace.id, environmentId: workspace.environments[0].id };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "Verbinden",
|
||||
"connect_formbricks": "Formbricks verbinden",
|
||||
"connected": "Verbunden",
|
||||
"contact": "Kontakt",
|
||||
"contacts": "Kontakte",
|
||||
"continue": "Weitermachen",
|
||||
"copied": "Kopiert",
|
||||
@@ -216,12 +217,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.",
|
||||
@@ -285,7 +286,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!",
|
||||
@@ -325,10 +328,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",
|
||||
@@ -420,7 +422,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",
|
||||
@@ -435,7 +436,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",
|
||||
@@ -459,7 +459,6 @@
|
||||
"url": "URL",
|
||||
"user": "Benutzer",
|
||||
"user_id": "Benutzer-ID",
|
||||
"user_not_found": "Benutzer nicht gefunden",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variablen-IDs",
|
||||
"variables": "Variablen",
|
||||
@@ -475,14 +474,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",
|
||||
@@ -667,7 +665,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",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "Connect",
|
||||
"connect_formbricks": "Connect Formbricks",
|
||||
"connected": "Connected",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacts",
|
||||
"continue": "Continue",
|
||||
"copied": "Copied",
|
||||
@@ -216,12 +217,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.",
|
||||
@@ -285,7 +286,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!",
|
||||
@@ -325,10 +328,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",
|
||||
@@ -420,7 +422,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",
|
||||
@@ -435,7 +436,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",
|
||||
@@ -459,7 +459,6 @@
|
||||
"url": "URL",
|
||||
"user": "User",
|
||||
"user_id": "User ID",
|
||||
"user_not_found": "User not found",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variable IDs",
|
||||
"variables": "Variables",
|
||||
@@ -475,14 +474,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",
|
||||
@@ -667,7 +665,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",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "Conectar",
|
||||
"connect_formbricks": "Conectar Formbricks",
|
||||
"connected": "Conectado",
|
||||
"contact": "Contacto",
|
||||
"contacts": "Contactos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
@@ -216,12 +217,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.",
|
||||
@@ -285,7 +286,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!",
|
||||
@@ -325,10 +328,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",
|
||||
@@ -420,7 +422,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",
|
||||
@@ -435,7 +436,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",
|
||||
@@ -459,7 +459,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",
|
||||
@@ -475,14 +474,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ú",
|
||||
@@ -667,7 +665,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",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "Connecter",
|
||||
"connect_formbricks": "Connecter Formbricks",
|
||||
"connected": "Connecté",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacts",
|
||||
"continue": "Continuer",
|
||||
"copied": "Copié",
|
||||
@@ -216,12 +217,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.",
|
||||
@@ -285,7 +286,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!",
|
||||
@@ -325,10 +328,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",
|
||||
@@ -420,7 +422,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",
|
||||
@@ -435,7 +436,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",
|
||||
@@ -459,7 +459,6 @@
|
||||
"url": "URL",
|
||||
"user": "Utilisateur",
|
||||
"user_id": "Identifiant d'utilisateur",
|
||||
"user_not_found": "Utilisateur non trouvé",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Identifiants variables",
|
||||
"variables": "Variables",
|
||||
@@ -475,14 +474,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",
|
||||
@@ -667,7 +665,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",
|
||||
|
||||
@@ -169,6 +169,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",
|
||||
@@ -216,12 +217,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.",
|
||||
@@ -285,7 +286,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!",
|
||||
@@ -325,10 +328,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",
|
||||
@@ -420,7 +422,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",
|
||||
@@ -435,7 +436,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",
|
||||
@@ -459,7 +459,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",
|
||||
@@ -475,14 +474,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",
|
||||
@@ -667,7 +665,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",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "接続",
|
||||
"connect_formbricks": "Formbricksを接続",
|
||||
"connected": "接続済み",
|
||||
"contact": "連絡先",
|
||||
"contacts": "連絡先",
|
||||
"continue": "続行",
|
||||
"copied": "コピーしました",
|
||||
@@ -216,12 +217,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": "この リソース は 存在 しない か、アクセス する ための 必要な 権限 が ありません。",
|
||||
@@ -285,7 +286,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": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
@@ -325,10 +328,9 @@
|
||||
"or": "または",
|
||||
"organization": "組織",
|
||||
"organization_id": "組織ID",
|
||||
"organization_not_found": "組織が見つかりません",
|
||||
"organization_settings": "組織設定",
|
||||
"organization_teams_not_found": "組織のチームが見つかりません",
|
||||
"other": "その他",
|
||||
"other_filters": "その他のフィルター",
|
||||
"others": "その他",
|
||||
"overlay_color": "オーバーレイの色",
|
||||
"overview": "概要",
|
||||
@@ -420,7 +422,6 @@
|
||||
"survey_id": "フォームID",
|
||||
"survey_languages": "フォームの言語",
|
||||
"survey_live": "フォーム公開中",
|
||||
"survey_not_found": "フォームが見つかりません",
|
||||
"survey_paused": "フォームは一時停止中です。",
|
||||
"survey_type": "フォームの種類",
|
||||
"surveys": "フォーム",
|
||||
@@ -435,7 +436,6 @@
|
||||
"team_name": "チーム名",
|
||||
"team_role": "チームの役割",
|
||||
"teams": "チーム",
|
||||
"teams_not_found": "チームが見つかりません",
|
||||
"text": "テキスト",
|
||||
"time": "時間",
|
||||
"time_to_finish": "所要時間",
|
||||
@@ -459,7 +459,6 @@
|
||||
"url": "URL",
|
||||
"user": "ユーザー",
|
||||
"user_id": "ユーザーID",
|
||||
"user_not_found": "ユーザーが見つかりません",
|
||||
"variable": "変数",
|
||||
"variable_ids": "変数ID",
|
||||
"variables": "変数",
|
||||
@@ -475,14 +474,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": "あなた",
|
||||
@@ -667,7 +665,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": "属性を作成",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "Verbinden",
|
||||
"connect_formbricks": "Sluit Formbricks aan",
|
||||
"connected": "Aangesloten",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacten",
|
||||
"continue": "Doorgaan",
|
||||
"copied": "Gekopieerd",
|
||||
@@ -216,12 +217,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.",
|
||||
@@ -285,7 +286,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!",
|
||||
@@ -325,10 +328,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",
|
||||
@@ -420,7 +422,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",
|
||||
@@ -435,7 +436,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",
|
||||
@@ -459,7 +459,6 @@
|
||||
"url": "URL",
|
||||
"user": "Gebruiker",
|
||||
"user_id": "Gebruikers-ID",
|
||||
"user_not_found": "Gebruiker niet gevonden",
|
||||
"variable": "Variabel",
|
||||
"variable_ids": "Variabele ID's",
|
||||
"variables": "Variabelen",
|
||||
@@ -475,14 +474,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",
|
||||
@@ -667,7 +665,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",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "Conectar",
|
||||
"connect_formbricks": "Conectar Formbricks",
|
||||
"connected": "conectado",
|
||||
"contact": "Contato",
|
||||
"contacts": "Contatos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
@@ -216,12 +217,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.",
|
||||
@@ -285,7 +286,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!",
|
||||
@@ -325,10 +328,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",
|
||||
@@ -420,7 +422,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",
|
||||
@@ -435,7 +436,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",
|
||||
@@ -459,7 +459,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",
|
||||
@@ -475,14 +474,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ê",
|
||||
@@ -667,7 +665,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",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "Conectar",
|
||||
"connect_formbricks": "Ligar Formbricks",
|
||||
"connected": "Conectado",
|
||||
"contact": "Contacto",
|
||||
"contacts": "Contactos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
@@ -216,12 +217,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.",
|
||||
@@ -285,7 +286,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ã!",
|
||||
@@ -325,10 +328,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",
|
||||
@@ -420,7 +422,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",
|
||||
@@ -435,7 +436,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",
|
||||
@@ -459,7 +459,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",
|
||||
@@ -475,14 +474,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ê",
|
||||
@@ -667,7 +665,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",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "Conectează",
|
||||
"connect_formbricks": "Conectează Formbricks",
|
||||
"connected": "Conectat",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacte",
|
||||
"continue": "Continuă",
|
||||
"copied": "Copiat",
|
||||
@@ -216,12 +217,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.",
|
||||
@@ -285,7 +286,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!",
|
||||
@@ -325,10 +328,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ă",
|
||||
@@ -420,7 +422,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",
|
||||
@@ -435,7 +436,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",
|
||||
@@ -459,7 +459,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",
|
||||
@@ -475,14 +474,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",
|
||||
@@ -667,7 +665,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",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "Подключить",
|
||||
"connect_formbricks": "Подключить Formbricks",
|
||||
"connected": "Подключено",
|
||||
"contact": "Контакт",
|
||||
"contacts": "Контакты",
|
||||
"continue": "Продолжить",
|
||||
"copied": "Скопировано",
|
||||
@@ -216,12 +217,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": "Этот ресурс не существует или у вас нет необходимых прав для доступа к нему.",
|
||||
@@ -285,7 +286,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": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
|
||||
@@ -325,10 +328,9 @@
|
||||
"or": "или",
|
||||
"organization": "Организация",
|
||||
"organization_id": "ID организации",
|
||||
"organization_not_found": "Организация не найдена",
|
||||
"organization_settings": "Настройки организации",
|
||||
"organization_teams_not_found": "Команды организации не найдены",
|
||||
"other": "Другое",
|
||||
"other_filters": "Другие фильтры",
|
||||
"others": "Другие",
|
||||
"overlay_color": "Цвет наложения",
|
||||
"overview": "Обзор",
|
||||
@@ -420,7 +422,6 @@
|
||||
"survey_id": "ID опроса",
|
||||
"survey_languages": "Языки опроса",
|
||||
"survey_live": "Опрос активен",
|
||||
"survey_not_found": "Опрос не найден",
|
||||
"survey_paused": "Опрос приостановлен.",
|
||||
"survey_type": "Тип опроса",
|
||||
"surveys": "Опросы",
|
||||
@@ -435,7 +436,6 @@
|
||||
"team_name": "Название команды",
|
||||
"team_role": "Роль в команде",
|
||||
"teams": "Команды",
|
||||
"teams_not_found": "Команды не найдены",
|
||||
"text": "Текст",
|
||||
"time": "Время",
|
||||
"time_to_finish": "Время до завершения",
|
||||
@@ -459,7 +459,6 @@
|
||||
"url": "URL",
|
||||
"user": "Пользователь",
|
||||
"user_id": "ID пользователя",
|
||||
"user_not_found": "Пользователь не найден",
|
||||
"variable": "Переменная",
|
||||
"variable_ids": "ID переменных",
|
||||
"variables": "Переменные",
|
||||
@@ -475,14 +474,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": "Вы",
|
||||
@@ -667,7 +665,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": "Создать атрибут",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "Anslut",
|
||||
"connect_formbricks": "Anslut Formbricks",
|
||||
"connected": "Ansluten",
|
||||
"contact": "Kontakt",
|
||||
"contacts": "Kontakter",
|
||||
"continue": "Fortsätt",
|
||||
"copied": "Kopierad",
|
||||
@@ -216,12 +217,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.",
|
||||
@@ -285,7 +286,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!",
|
||||
@@ -325,10 +328,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",
|
||||
@@ -420,7 +422,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",
|
||||
@@ -435,7 +436,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",
|
||||
@@ -459,7 +459,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",
|
||||
@@ -475,14 +474,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",
|
||||
@@ -667,7 +665,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",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "连接",
|
||||
"connect_formbricks": "连接 Formbricks",
|
||||
"connected": "已连接",
|
||||
"contact": "联系人",
|
||||
"contacts": "联系人",
|
||||
"continue": "继续",
|
||||
"copied": "已复制",
|
||||
@@ -216,12 +217,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": "这个资源不存在或您没有权限访问它。",
|
||||
@@ -285,7 +286,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": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
@@ -325,10 +328,9 @@
|
||||
"or": "或",
|
||||
"organization": "组织",
|
||||
"organization_id": "组织 ID",
|
||||
"organization_not_found": "组织 未找到",
|
||||
"organization_settings": "组织 设置",
|
||||
"organization_teams_not_found": "未找到 组织 团队",
|
||||
"other": "其他",
|
||||
"other_filters": "其他筛选条件",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆盖层颜色",
|
||||
"overview": "概览",
|
||||
@@ -420,7 +422,6 @@
|
||||
"survey_id": "调查 ID",
|
||||
"survey_languages": "调查 语言",
|
||||
"survey_live": "调查 运行中",
|
||||
"survey_not_found": "调查 未找到",
|
||||
"survey_paused": "调查 暂停。",
|
||||
"survey_type": "调查 类型",
|
||||
"surveys": "调查",
|
||||
@@ -435,7 +436,6 @@
|
||||
"team_name": "团队 名称",
|
||||
"team_role": "团队角色",
|
||||
"teams": "团队",
|
||||
"teams_not_found": "未找到 团队",
|
||||
"text": "文本",
|
||||
"time": "时间",
|
||||
"time_to_finish": "完成 时间",
|
||||
@@ -459,7 +459,6 @@
|
||||
"url": "URL",
|
||||
"user": "用户",
|
||||
"user_id": "用户 ID",
|
||||
"user_not_found": "用户 不存在",
|
||||
"variable": "变量",
|
||||
"variable_ids": "变量 ID",
|
||||
"variables": "变量",
|
||||
@@ -475,14 +474,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": "你 ",
|
||||
@@ -667,7 +665,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": "创建属性",
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
"connect": "連線",
|
||||
"connect_formbricks": "連線 Formbricks",
|
||||
"connected": "已連線",
|
||||
"contact": "聯絡人",
|
||||
"contacts": "聯絡人",
|
||||
"continue": "繼續",
|
||||
"copied": "已 複製",
|
||||
@@ -216,12 +217,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": "此資源不存在或您沒有存取權限。",
|
||||
@@ -285,7 +286,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": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
@@ -325,10 +328,9 @@
|
||||
"or": "或",
|
||||
"organization": "組織",
|
||||
"organization_id": "組織 ID",
|
||||
"organization_not_found": "找不到組織",
|
||||
"organization_settings": "組織設定",
|
||||
"organization_teams_not_found": "找不到組織團隊",
|
||||
"other": "其他",
|
||||
"other_filters": "其他篩選條件",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆蓋層顏色",
|
||||
"overview": "概覽",
|
||||
@@ -420,7 +422,6 @@
|
||||
"survey_id": "問卷 ID",
|
||||
"survey_languages": "問卷語言",
|
||||
"survey_live": "問卷已上線",
|
||||
"survey_not_found": "找不到問卷",
|
||||
"survey_paused": "問卷已暫停。",
|
||||
"survey_type": "問卷類型",
|
||||
"surveys": "問卷",
|
||||
@@ -435,7 +436,6 @@
|
||||
"team_name": "團隊名稱",
|
||||
"team_role": "團隊角色",
|
||||
"teams": "團隊",
|
||||
"teams_not_found": "找不到團隊",
|
||||
"text": "文字",
|
||||
"time": "時間",
|
||||
"time_to_finish": "完成時間",
|
||||
@@ -459,7 +459,6 @@
|
||||
"url": "網址",
|
||||
"user": "使用者",
|
||||
"user_id": "使用者 ID",
|
||||
"user_not_found": "找不到使用者",
|
||||
"variable": "變數",
|
||||
"variable_ids": "變數 ID",
|
||||
"variables": "變數",
|
||||
@@ -475,14 +474,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": "您",
|
||||
@@ -667,7 +665,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
-7
@@ -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) {
|
||||
|
||||
@@ -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 workspace = await getWorkspaceByEnvironmentId(environment.id);
|
||||
if (!workspace) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const workspacePermission = await getWorkspacePermissionByUserId(session.user.id, workspace.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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { updateUser } from "./lib/update-user";
|
||||
|
||||
@@ -45,15 +46,10 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const environmentId = params.environmentId.trim();
|
||||
const idParam = params.environmentId.trim();
|
||||
|
||||
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
|
||||
// This catches all invalid formats including:
|
||||
// - null/undefined passed as string "null" or "undefined"
|
||||
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
|
||||
// - Empty or whitespace-only IDs
|
||||
// - Any other invalid CUID v1 format
|
||||
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
// Validate CUID format
|
||||
const cuidValidation = ZEnvironmentId.safeParse(idParam);
|
||||
if (!cuidValidation.success) {
|
||||
logger.warn(
|
||||
{
|
||||
@@ -61,13 +57,22 @@ export const POST = withV1ApiWrapper({
|
||||
url: req.url,
|
||||
validationError: cuidValidation.error.issues[0]?.message,
|
||||
},
|
||||
"Invalid CUID v1 format detected"
|
||||
"Invalid CUID format detected"
|
||||
);
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
|
||||
const resolved = await resolveClientApiIds(idParam);
|
||||
if (!resolved) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Environment", idParam),
|
||||
};
|
||||
}
|
||||
const { environmentId } = resolved;
|
||||
|
||||
const jsonInput = await req.json();
|
||||
|
||||
// Basic input validation without Zod overhead
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TWorkspace } from "@formbricks/types/workspace";
|
||||
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -20,7 +21,7 @@ export const ContactsSecondaryNavigation = async ({
|
||||
workspace = await getWorkspaceByEnvironmentId(environmentId);
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 workspace = await getWorkspaceByEnvironmentId(params.environmentId);
|
||||
if (!workspace) {
|
||||
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);
|
||||
|
||||
@@ -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 { 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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AccessView } from "@/modules/ee/teams/workspace-teams/components/access-view";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -15,7 +16,7 @@ export const WorkspaceTeams = async (props: { params: Promise<{ environmentId: s
|
||||
const teams = await getTeamsByWorkspaceId(workspace.id);
|
||||
|
||||
if (!teams) {
|
||||
throw new Error(t("common.teams_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.teams"), null);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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";
|
||||
@@ -47,7 +47,7 @@ export const updateWorkspaceBrandingAction = authenticatedActionClient
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
const canRemoveBranding = await getRemoveBrandingPermission(organizationId);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { TUser } from "@formbricks/types/user";
|
||||
@@ -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 workspace not found", async () => {
|
||||
vi.mocked(getWorkspaceByEnvironmentId).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 () => {
|
||||
|
||||
@@ -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 (!workspace) {
|
||||
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, workspace, 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (!workspaceWithTeamIds) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(workspaceWithTeamIds.organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), workspaceWithTeamIds.organizationId);
|
||||
}
|
||||
|
||||
const isSurveyCreationDeletionDisabled = isMember && hasReadAccess;
|
||||
|
||||
@@ -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 workspace = await getWorkspaceWithTeamIdsByEnvironmentId(params.environmentId);
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const { session, isBilling, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
@@ -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 workspace = await getWorkspaceWithTeamIdsByEnvironmentId(environmentId);
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
if (isReadOnly) {
|
||||
|
||||
@@ -43,14 +43,14 @@ export interface ButtonProps
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, loading, asChild = false, children, ...props }, ref) => {
|
||||
({ className, variant, size, loading, asChild = false, disabled, children, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, loading, className }))}
|
||||
disabled={loading}
|
||||
ref={ref}
|
||||
{...props}>
|
||||
{...props}
|
||||
disabled={loading || disabled}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
|
||||
+6
-7
@@ -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.workspaceId);
|
||||
@@ -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";
|
||||
|
||||
@@ -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 { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -48,7 +48,7 @@ export const updateWorkspaceAction = authenticatedActionClient.inputSchema(ZUpda
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const canRemoveBranding = await getRemoveBrandingPermission(organizationId);
|
||||
|
||||
@@ -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 WorkspaceLookSettingsPage = async (props: { params: Promise<{ envir
|
||||
const workspace = await getWorkspaceByEnvironmentId(params.environmentId);
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found");
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const canRemoveBranding = await getRemoveBrandingPermission(organization.id);
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
# Plan: Deprecate Environments in Formbricks
|
||||
|
||||
**Issue**: https://github.com/formbricks/internal/issues/1501
|
||||
|
||||
## Context
|
||||
|
||||
Formbricks currently has a 4-level hierarchy: **Organization → Project → Environment (prod/dev) → Resources**. The "Environment" layer adds complexity with minimal value — the only real difference between prod and dev is separate API keys and a UI badge. The UI already calls "Project" a "Workspace".
|
||||
|
||||
**Goal**: Collapse the Environment layer so resources live directly under Project. The production environment merges into the workspace identity. Dev environments with data become separate new workspaces.
|
||||
|
||||
**Key decisions**:
|
||||
- DB model stays as `Project` (no table rename)
|
||||
- SDK will accept `workspaceId` as new param, `environmentId` as deprecated alias
|
||||
- Dev environments with data get promoted to separate workspaces
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
```
|
||||
Organization
|
||||
└── Project ("Workspace" in UI)
|
||||
├── Environment (production) ──→ surveys, contacts, webhooks, tags, ...
|
||||
└── Environment (development) ──→ surveys, contacts, webhooks, tags, ...
|
||||
```
|
||||
|
||||
Every project always has exactly 2 environments. The only differences between them:
|
||||
- Separate data (contacts, responses, attributes, integrations, webhooks, segments, etc.)
|
||||
- Separate API keys (`ApiKeyEnvironment` grants per-environment permissions)
|
||||
- A red warning banner in the dev UI, plus an environment switcher breadcrumb
|
||||
|
||||
Key metrics:
|
||||
- **564 files** in `apps/web` reference `environmentId`
|
||||
- **52 files** in `packages` reference `environmentId`
|
||||
- **68+ route directories** under `/environments/[environmentId]/`
|
||||
- **22 API endpoint directories** keyed by `[environmentId]`
|
||||
- **8 resource tables** FK to Environment: `Survey`, `Contact`, `ActionClass`, `ContactAttributeKey`, `Webhook`, `Tag`, `Segment`, `Integration`
|
||||
- **SDK** requires `environmentId` to initialize, all client APIs use `/api/v1/client/[environmentId]/...`
|
||||
- **Storage** paths: `private/${environmentId}/${fileName}`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Add `projectId` Column to All Environment-Owned Models (PR 1 — Small, Low Risk)
|
||||
|
||||
Add an **optional** `projectId` column alongside the existing `environmentId` on every model that currently only references Environment.
|
||||
|
||||
**Why**: Today, Survey has `environmentId` pointing to Environment, and you have to join through Environment to reach Project. We need Survey to point directly to Project. But we can't just switch the FK in one shot — that would break everything. So we add a new nullable `projectId` column alongside the existing `environmentId`. No code changes, no runtime impact. Just schema preparation.
|
||||
|
||||
**Modify**: `packages/database/schema.prisma`
|
||||
- Add `projectId String?` + FK to Project + index to: `Survey`, `Contact`, `ActionClass`, `ContactAttributeKey`, `Webhook`, `Tag`, `Segment`, `Integration`
|
||||
- Add reverse relations on the `Project` model
|
||||
- New Prisma migration file
|
||||
|
||||
No code changes. No runtime behavior change. All new columns are NULL.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Backfill `projectId` (PR 2 — Small, Medium Risk)
|
||||
|
||||
Data migration to populate `projectId` on every existing row.
|
||||
|
||||
**Why**: The new `projectId` columns are all NULL. We need to populate them by joining through the Environment table: `Survey.environmentId → Environment.id → Environment.projectId`. After this, every row has both `environmentId` (old) and `projectId` (new) filled in, but the app still only reads `environmentId`.
|
||||
|
||||
```sql
|
||||
UPDATE "Survey" s SET "projectId" = e."projectId"
|
||||
FROM "Environment" e WHERE s."environmentId" = e."id" AND s."projectId" IS NULL;
|
||||
-- Repeat for all 8 tables
|
||||
```
|
||||
|
||||
**Create**: Migration script (idempotent — only updates rows where `projectId IS NULL`)
|
||||
|
||||
App behavior unchanged. New columns now populated but not yet read.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Dual-Write (PR 3 — Large, Medium Risk)
|
||||
|
||||
All create/update operations write both `environmentId` AND `projectId`.
|
||||
|
||||
**Why**: New rows created after the backfill would still have `projectId = NULL` because the app code doesn't know about the new column yet. We update every `prisma.survey.create(...)`, `prisma.contact.create(...)`, etc. to write both `environmentId` and `projectId`. Now every new row gets both values. Old code still reads `environmentId` — nothing breaks.
|
||||
|
||||
**Key files to modify**:
|
||||
- `apps/web/lib/survey/service.ts` — `createSurvey`
|
||||
- `apps/web/lib/environment/service.ts` — `createEnvironment` (creates default ContactAttributeKeys)
|
||||
- `apps/web/modules/projects/settings/lib/project.ts` — `createProject`
|
||||
- `apps/web/modules/survey/list/lib/survey.ts` — `copySurveyToOtherEnvironment`
|
||||
- `apps/web/modules/survey/components/template-list/lib/survey.ts` — `createSurvey`
|
||||
- `apps/web/lib/actionClass/service.ts` — `createActionClass`
|
||||
- `apps/web/modules/survey/editor/lib/action-class.ts` — `createActionClass`
|
||||
- `apps/web/modules/ee/contacts/lib/contacts.ts` — `processCsvRecord`, `createMissingAttributeKeys`
|
||||
- `apps/web/modules/ee/contacts/api/v2/management/contacts/lib/contact.ts` — `createContact`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/displays/lib/display.ts` — `createDisplay` (creates contacts)
|
||||
- `apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts` — `createContactAttributeKey`
|
||||
- `apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts` — `createContactAttributeKey`
|
||||
- `apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts` — `createContactAttributeKey`
|
||||
- `apps/web/modules/integrations/webhooks/lib/webhook.ts` — `createWebhook`
|
||||
- `apps/web/modules/api/v2/management/webhooks/lib/webhook.ts` — `createWebhook`
|
||||
- `apps/web/app/api/v1/webhooks/lib/webhook.ts` — `createWebhook`
|
||||
- `apps/web/lib/tag/service.ts` — `createTag`
|
||||
- `apps/web/modules/ee/contacts/segments/lib/segments.ts` — `createSegment`, `cloneSegment`, `resetSegmentInSurvey`
|
||||
- `apps/web/lib/integration/service.ts` — `createOrUpdateIntegration`
|
||||
|
||||
Pattern:
|
||||
```typescript
|
||||
// Resolve environmentId to projectId using existing getEnvironment()
|
||||
const environment = await getEnvironment(environmentId);
|
||||
const projectId = environment.projectId;
|
||||
await prisma.survey.create({ data: { environmentId, projectId, ...rest } });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Switch Internal Reads to `projectId` (PR 4 — Very Large, High Risk)
|
||||
|
||||
Change internal (non-API) queries from `WHERE environmentId = ?` to `WHERE projectId = ?`.
|
||||
|
||||
**Why**: This is the actual migration. Every query that says `WHERE environmentId = X` changes to `WHERE projectId = X`. Functions like `getSurveys(environmentId)` become `getSurveys(projectId)`. The layout at `/environments/[environmentId]/layout.tsx` resolves the environmentId from the URL to a projectId early on and passes projectId downstream. After this phase, the app internally thinks in terms of projects, not environments, even though URLs still say `[environmentId]`.
|
||||
|
||||
**Key files**:
|
||||
- `apps/web/modules/survey/list/lib/survey.ts` — `getSurveys(environmentId)` → `getSurveys(projectId)`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts` — `getEnvironmentStateData`
|
||||
- `apps/web/modules/environments/lib/utils.ts` — `getEnvironmentAuth`, `getEnvironmentLayoutData`
|
||||
- `apps/web/app/(app)/environments/[environmentId]/layout.tsx` — resolve `projectId` early, pass to context
|
||||
- `apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx` — add `projectId`
|
||||
- All page server components that pass `environmentId` to service functions
|
||||
|
||||
URL still has `[environmentId]`. Each page resolves `environmentId → projectId` at the top.
|
||||
|
||||
**This PR can be split further** by migrating one resource type at a time (surveys first, then contacts, then actions, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Client API Backwards Compatibility (PR 5 — Medium, Medium Risk)
|
||||
|
||||
Make `/api/v1/client/[environmentId]/...` and `/api/v2/client/[environmentId]/...` accept either an `environmentId` or a `projectId`.
|
||||
|
||||
**Why**: The SDK sends requests to `/api/v1/client/[environmentId]/...`. Existing deployed SDKs will keep sending environmentIds. New SDKs will send projectIds. Each route handler needs to accept either and resolve to a projectId internally. This ensures old SDK versions don't break.
|
||||
|
||||
**Add fallback resolution at top of each route handler**:
|
||||
```typescript
|
||||
// Try Environment table first, fall back to Project table
|
||||
let projectId: string;
|
||||
const environment = await prisma.environment.findUnique({ where: { id: params.environmentId } });
|
||||
if (environment) {
|
||||
projectId = environment.projectId;
|
||||
} else {
|
||||
projectId = params.environmentId; // caller passed a projectId directly
|
||||
}
|
||||
```
|
||||
|
||||
**Files**:
|
||||
- `apps/web/app/api/v1/client/[environmentId]/environment/route.ts`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/displays/route.ts`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/responses/route.ts`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/storage/route.ts`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/user/route.ts`
|
||||
- `apps/web/app/api/v2/client/[environmentId]/` — all routes
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Management API + API Key Migration (PR 6 — Medium, Medium Risk)
|
||||
|
||||
**Why**: The `ApiKeyEnvironment` model grants per-environment permissions. API keys used by integrations (Zapier, Make, etc.) reference environmentIds. These need to work at the project level. The management API endpoints that accept `environmentId` in request bodies need to also accept `projectId`.
|
||||
|
||||
- Modify `ApiKeyEnvironment` to also support project-level permissions (or add `projectId` to the model)
|
||||
- Update `apps/web/app/api/v1/auth.ts` — `authenticateRequest` resolves environment permissions to project
|
||||
- Management route handlers accept `environmentId` OR `projectId` in request bodies
|
||||
- API key management UI in `modules/organization/settings/api-keys/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Storage Path Migration (PR 7 — Medium, Medium Risk)
|
||||
|
||||
**Why**: Uploaded files are stored at paths like `private/{environmentId}/{fileName}`. New uploads should use `{projectId}/...`, but old files still live at the old paths. Downloads need to check both locations for backward compatibility.
|
||||
|
||||
- New uploads use `{projectId}/{accessType}/{fileName}`
|
||||
- Downloads check both `{projectId}/...` and `{environmentId}/...` paths for backwards compat
|
||||
- `apps/web/modules/storage/service.ts`
|
||||
- `apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts`
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Dev Environment Data Migration (PR 8 — Large, High Risk)
|
||||
|
||||
**Why**: Currently each project has a prod and dev environment. After the migration, there's no "environment" concept — just projects. Dev environments with no data can be discarded. Dev environments with data need to be promoted into new standalone projects so that data isn't lost.
|
||||
|
||||
For each Project with a development Environment that has data:
|
||||
1. Create new Project named `{name} (Dev)` in the same Organization
|
||||
2. Create a production Environment for the new Project
|
||||
3. Re-parent all dev environment resources to the new Project (update `projectId`)
|
||||
4. Re-parent resources to the new production environment (update `environmentId`)
|
||||
|
||||
For development environments with NO data: leave as-is (will be cleaned up later).
|
||||
|
||||
**Create**: Idempotent migration script in `packages/database/migration/` or `scripts/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: New `/workspaces/[projectId]/` Routes + Redirects (PR 9 — Very Large, High Risk)
|
||||
|
||||
**Why**: The URL currently says `/environments/[environmentId]/surveys/...`. After the migration, it should say `/workspaces/[projectId]/surveys/...`. This phase creates the new route group mirroring the old structure, removes the environment switcher breadcrumb, and adds redirects so old bookmarked URLs still work.
|
||||
|
||||
- Create `/apps/web/app/(app)/workspaces/[projectId]/` route group mirroring the environments structure
|
||||
- New layout resolves `projectId` directly
|
||||
- Old `/environments/[environmentId]/...` routes redirect to `/workspaces/{projectId}/...`
|
||||
- Update `apps/web/app/page.tsx` to redirect to workspace URLs
|
||||
- Remove environment switcher breadcrumb
|
||||
|
||||
**Can be split** into sub-PRs: layout first, then surveys, then settings, etc.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Make `projectId` NOT NULL (PR 10 — Small, Low Risk)
|
||||
|
||||
**Why**: At this point, every row has `projectId` populated (backfill + dual-write), and all reads use `projectId`. Now we can safely make it required in the schema. This is a safety net — the DB will reject any row that somehow doesn't have a projectId.
|
||||
|
||||
```sql
|
||||
ALTER TABLE "Survey" ALTER COLUMN "projectId" SET NOT NULL;
|
||||
-- Repeat for all 8 tables
|
||||
```
|
||||
|
||||
Pre-check: verify no NULL values remain.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: JS SDK Update (PR 11 — Medium, Low Risk)
|
||||
|
||||
**Why**: Add `workspaceId` as the new init parameter. `environmentId` keeps working as a deprecated alias. Existing integrations don't break.
|
||||
|
||||
- `packages/js-core/src/types/config.ts` — add `workspaceId` to `TConfigInput`
|
||||
- `packages/js-core/src/lib/common/setup.ts` — accept `workspaceId`, fall back to `environmentId`
|
||||
- `environmentId` continues working as deprecated alias indefinitely
|
||||
|
||||
```typescript
|
||||
// New:
|
||||
formbricks.init({ workspaceId: "cxxx", appUrl: "..." })
|
||||
// Old (still works):
|
||||
formbricks.init({ environmentId: "cxxx", appUrl: "..." })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After each PR:
|
||||
1. `pnpm build` passes
|
||||
2. Existing tests pass (`pnpm test`)
|
||||
3. Manual smoke test: create survey, submit response, check dashboard
|
||||
4. SDK initialization works with existing `environmentId`
|
||||
|
||||
After full migration:
|
||||
- Old environment URLs redirect correctly
|
||||
- Old API keys work
|
||||
- Old SDK `environmentId` init works
|
||||
- New `workspaceId` SDK init works
|
||||
- Storage files accessible via both old and new paths
|
||||
- Dev environments with data are separate workspaces
|
||||
|
||||
---
|
||||
|
||||
## PR Summary
|
||||
|
||||
| PR | Phase | Description | Size | Risk |
|
||||
|----|-------|-------------|------|------|
|
||||
| 1 | 1 | Add nullable `projectId` columns | S | Low |
|
||||
| 2 | 2 | Backfill `projectId` data migration | S | Med |
|
||||
| 3 | 3 | Dual-write `projectId` on all creates | L | Med |
|
||||
| 4 | 4 | Switch reads to `projectId` | XL | High |
|
||||
| 5 | 5 | Client API backwards compat | M | Med |
|
||||
| 6 | 6 | Management API + API key migration | M | Med |
|
||||
| 7 | 7 | Storage path migration | M | Med |
|
||||
| 8 | 8 | Dev environment → workspace promotion | L | High |
|
||||
| 9 | 9 | New workspace routes + redirects | XL | High |
|
||||
| 10 | 10 | Make `projectId` NOT NULL | S | Low |
|
||||
| 11 | 11 | JS SDK `workspaceId` support | M | Low |
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -34,21 +34,13 @@ Here are the requirements for setting up Formbricks on Linux:
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Create a `.env` file based on `.env.example`:**
|
||||
4. **Create a development `.env` file and generate the required secrets:**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
pnpm dev:setup
|
||||
```
|
||||
|
||||
5. **Generate & set the 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.
|
||||
|
||||
@@ -56,7 +48,7 @@ Here are the requirements for setting up Formbricks on Linux:
|
||||
pnpm agents:update
|
||||
```
|
||||
|
||||
7. **Start the development setup:**
|
||||
6. **Start the development setup:**
|
||||
```bash
|
||||
pnpm go
|
||||
```
|
||||
|
||||
@@ -34,21 +34,13 @@ icon: "apple"
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Create a `.env` file from the example:**
|
||||
4. **Create a development `.env` file and generate the required secrets:**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
pnpm dev:setup
|
||||
```
|
||||
|
||||
5. **Generate & set secret values (using BSD sed syntax for macOS):**
|
||||
|
||||
```bash
|
||||
sed -i '' '/^ENCRYPTION_KEY=/s|.*|ENCRYPTION_KEY='$(openssl rand -hex 32)'|' .env
|
||||
sed -i '' '/^NEXTAUTH_SECRET=/s|.*|NEXTAUTH_SECRET='$(openssl rand -hex 32)'|' .env
|
||||
sed -i '' '/^CRON_SECRET=/s|.*|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.
|
||||
|
||||
@@ -56,7 +48,7 @@ icon: "apple"
|
||||
pnpm agents:update
|
||||
```
|
||||
|
||||
7. **Start the development setup:**
|
||||
6. **Start the development setup:**
|
||||
```bash
|
||||
pnpm go
|
||||
```
|
||||
|
||||
@@ -37,22 +37,13 @@ icon: "windows"
|
||||
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 (Linux commands work in WSL2):**
|
||||
|
||||
```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.
|
||||
|
||||
@@ -60,7 +51,7 @@ icon: "windows"
|
||||
pnpm agents:update
|
||||
```
|
||||
|
||||
7. **Start the development setup:**
|
||||
6. **Start the development setup:**
|
||||
```bash
|
||||
pnpm go
|
||||
```
|
||||
|
||||
+2
-1
@@ -42,7 +42,8 @@
|
||||
"generate-translations": "pnpm i18n:web:generate && pnpm i18n:surveys:generate",
|
||||
"scan-translations": "pnpm --filter @formbricks/i18n-utils scan-translations",
|
||||
"i18n": "pnpm generate-translations && pnpm scan-translations",
|
||||
"i18n:validate": "pnpm scan-translations"
|
||||
"i18n:validate": "pnpm scan-translations",
|
||||
"dev:setup": "bash scripts/setup-dev-env.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -73,7 +73,7 @@ function Consent({
|
||||
/>
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<label
|
||||
|
||||
@@ -83,7 +83,7 @@ function CTA({
|
||||
/>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="relative space-y-2">
|
||||
<div className="relative space-y-2" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
{buttonExternal ? (
|
||||
|
||||
@@ -161,7 +161,7 @@ function DateElement({
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{/* Calendar - Always visible */}
|
||||
<div className="w-full">
|
||||
|
||||
@@ -292,7 +292,7 @@ function FileUpload({
|
||||
imageAltText={imageAltText}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<div
|
||||
|
||||
@@ -112,7 +112,7 @@ function FormField({
|
||||
/>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<div className="space-y-3">
|
||||
{visibleFields.map((field) => {
|
||||
|
||||
@@ -94,7 +94,7 @@ function Matrix({
|
||||
/>
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
{/* Table container with overflow for mobile */}
|
||||
|
||||
@@ -145,7 +145,7 @@ function DropdownVariant({
|
||||
searchPlaceholder,
|
||||
searchNoResultsText,
|
||||
}: Readonly<DropdownVariantProps>): React.JSX.Element {
|
||||
const handleOptionToggle = (optionId: string) => {
|
||||
const handleOptionToggle = (optionId: string): void => {
|
||||
if (selectedValues.includes(optionId)) {
|
||||
handleOptionRemove(optionId);
|
||||
} else {
|
||||
@@ -540,7 +540,7 @@ function MultiSelect({
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
{variant === "dropdown" ? (
|
||||
<DropdownVariant
|
||||
inputId={inputId}
|
||||
|
||||
@@ -172,7 +172,7 @@ function NPS({
|
||||
/>
|
||||
|
||||
{/* NPS Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full px-[2px]" dir={dir}>
|
||||
<legend className="sr-only">NPS rating options</legend>
|
||||
|
||||
@@ -79,7 +79,7 @@ function OpenText({
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} />
|
||||
{/* Input or Textarea */}
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -106,7 +106,7 @@ function PictureSelect({
|
||||
/>
|
||||
|
||||
{/* Picture Grid - 2 columns */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{allowMulti ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
@@ -223,7 +223,7 @@ function Ranking({
|
||||
/>
|
||||
|
||||
{/* Ranking Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full" dir={dir}>
|
||||
<legend className="sr-only">Ranking options</legend>
|
||||
|
||||
@@ -407,7 +407,7 @@ function Rating({
|
||||
/>
|
||||
|
||||
{/* Rating Options */}
|
||||
<div className="relative">
|
||||
<div className="relative" data-element-input>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full" dir={dir}>
|
||||
<legend className="sr-only">Rating options</legend>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user