mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-21 11:30:27 -05:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66b5185e33 | |||
| 319523131f | |||
| b5eaa4c7fd | |||
| 995c03bc01 | |||
| b4395a48c5 | |||
| 461e3893fe | |||
| 735a9f84ec | |||
| 8cb8d734cf | |||
| 44d5530b48 | |||
| a314eb391e | |||
| 6c34c316d0 | |||
| 4f26278f16 | |||
| b975e7fa2e | |||
| 6c3052f9e4 | |||
| 5bb8119ebf | |||
| 02411277d4 | |||
| 4cfb8c6d7b | |||
| e74a51a5ff | |||
| 29cc6a10fe | |||
| 01f765e969 | |||
| 9366960f18 | |||
| 697dc9cc99 | |||
| 83bc272ed2 | |||
| 59cc9c564e | |||
| 20dc147682 | |||
| 2bb7a6f277 | |||
| deb062dd03 | |||
| 474be86d33 | |||
| e7ca66ed77 | |||
| 2b49dbecd3 | |||
| 6da4c6f352 | |||
| 659b240fca | |||
| 19c0b1d14d | |||
| b4472f48e9 |
@@ -0,0 +1,9 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "formbricks"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
pnpm install
|
||||
pnpm dev:setup
|
||||
'''
|
||||
@@ -94,6 +94,12 @@ EMAIL_VERIFICATION_DISABLED=1
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
PASSWORD_RESET_DISABLED=1
|
||||
|
||||
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
|
||||
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
|
||||
|
||||
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
|
||||
# DEBUG_SHOW_RESET_LINK=1
|
||||
|
||||
# Email login. Disable the ability for users to login with email.
|
||||
# EMAIL_AUTH_DISABLED=1
|
||||
|
||||
@@ -185,6 +191,14 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Ignore Rate Limiting across the Formbricks app
|
||||
# RATE_LIMITING_DISABLED=1
|
||||
|
||||
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
|
||||
# TELEMETRY_DISABLED=1
|
||||
|
||||
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
|
||||
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
|
||||
# that need to send webhooks to internal services.
|
||||
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
|
||||
|
||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ yarn-error.log*
|
||||
.direnv
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
**/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
@@ -127,34 +127,10 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
|
||||
|
||||
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
||||
|
||||
If you opt for self-hosting Formbricks, here are a few options to consider:
|
||||
|
||||
#### Docker
|
||||
|
||||
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
||||
|
||||
#### Community-managed One Click Hosting
|
||||
|
||||
##### Railway
|
||||
|
||||
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
|
||||
|
||||
[](https://railway.app/new/template/PPDzCd)
|
||||
|
||||
##### RepoCloud
|
||||
|
||||
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
|
||||
|
||||
[](https://repocloud.io/details/?app_id=254)
|
||||
|
||||
##### Zeabur
|
||||
|
||||
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
||||
|
||||
[](https://zeabur.com/templates/G4TUJL)
|
||||
|
||||
<a id="development"></a>
|
||||
|
||||
## 👨💻 Development
|
||||
|
||||
### Prerequisites
|
||||
@@ -247,4 +223,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
|
||||
|
||||
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
||||
|
||||
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
||||
<a id="readme-de"></a>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
@@ -20,12 +21,12 @@ const Page = async (props: ConnectPageProps) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
||||
@@ -23,22 +24,22 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
const t = await getTranslate();
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const projects = await getUserProjects(session.user.id, organizationId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = async (props: {
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||
@@ -36,7 +36,7 @@ const ProjectOnboardingLayout = async (props: {
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+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 [organizationProjectsLimit, organizationProjectsCount] = 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 { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||
@@ -45,7 +46,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||
|
||||
if (!organizationTeams) {
|
||||
throw new Error(t("common.organization_teams_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.team"), null);
|
||||
}
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
|
||||
@@ -17,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import {
|
||||
AuthorizationError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -46,7 +50,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
@@ -42,7 +43,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
|
||||
// Validate that project permission exists for members
|
||||
if (isMember && !projectPermission) {
|
||||
throw new Error(t("common.workspace_permission_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -116,13 +115,6 @@ export const MainNavigation = ({
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
},
|
||||
{
|
||||
name: t("common.workflows"),
|
||||
href: `/environments/${environment.id}/workflows`,
|
||||
icon: WorkflowIcon,
|
||||
isActive: pathname?.includes("/workflows"),
|
||||
isHidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
@@ -130,7 +122,7 @@ export const MainNavigation = ({
|
||||
isActive: pathname?.includes("/workspace"),
|
||||
},
|
||||
],
|
||||
[t, environment.id, pathname, isFormbricksCloud]
|
||||
[t, environment.id, pathname]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -20,15 +21,15 @@ const AccountSettingsLayout = async (props: {
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
+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) {
|
||||
|
||||
+8
-3
@@ -10,15 +10,16 @@ import {
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
|
||||
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
|
||||
return {
|
||||
@@ -85,11 +86,15 @@ export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPerso
|
||||
|
||||
export const resetPasswordAction = authenticatedActionClient.action(
|
||||
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
|
||||
if (PASSWORD_RESET_DISABLED) {
|
||||
throw new OperationNotAllowedError("Password reset is disabled");
|
||||
}
|
||||
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
await requestPasswordReset(ctx.user, "profile");
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+77
-20
@@ -3,13 +3,41 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import type { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
async function updateOrganizationAction<T extends z.ZodRawShape>({
|
||||
ctx,
|
||||
organizationId,
|
||||
schema,
|
||||
data,
|
||||
roles,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
organizationId: string;
|
||||
schema: z.ZodObject<T>;
|
||||
data: z.infer<z.ZodObject<T>>;
|
||||
roles: TOrganizationRole[];
|
||||
}) {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [{ type: "organization", schema, data, roles }],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const oldObject = await getOrganization(organizationId);
|
||||
const result = await updateOrganization(organizationId, data);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
const ZUpdateOrganizationNameAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
@@ -18,26 +46,55 @@ const ZUpdateOrganizationNameAction = z.object({
|
||||
export const updateOrganizationNameAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateOrganizationNameAction)
|
||||
.action(
|
||||
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
})
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"organization",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
|
||||
}) =>
|
||||
updateOrganizationAction({
|
||||
ctx,
|
||||
organizationId: parsedInput.organizationId,
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner"],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateOrganizationAISettingsAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZOrganizationUpdateInput.pick({ isAISmartToolsEnabled: true, isAIDataAnalysisEnabled: true }),
|
||||
});
|
||||
|
||||
export const updateOrganizationAISettingsAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateOrganizationAISettingsAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"organization",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
|
||||
}) =>
|
||||
updateOrganizationAction({
|
||||
ctx,
|
||||
organizationId: parsedInput.organizationId,
|
||||
schema: ZOrganizationUpdateInput.pick({
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
}),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner", "manager"],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteOrganizationAction = z.object({
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
|
||||
interface AISettingsToggleProps {
|
||||
organization: TOrganization;
|
||||
membershipRole?: TOrganizationRole;
|
||||
}
|
||||
|
||||
export const AISettingsToggle = ({ organization, membershipRole }: Readonly<AISettingsToggleProps>) => {
|
||||
const [loadingField, setLoadingField] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const canEdit = isOwner || isManager;
|
||||
|
||||
const handleToggle = async (
|
||||
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
|
||||
checked: boolean
|
||||
) => {
|
||||
setLoadingField(field);
|
||||
try {
|
||||
const response = await updateOrganizationAISettingsAction({
|
||||
organizationId: organization.id,
|
||||
data: { [field]: checked },
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong"));
|
||||
} finally {
|
||||
setLoadingField(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={organization.isAISmartToolsEnabled}
|
||||
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
|
||||
htmlId="ai-smart-tools-toggle"
|
||||
title={t("environments.settings.general.ai_smart_tools_enabled")}
|
||||
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
|
||||
disabled={loadingField !== null || !canEdit}
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
isChecked={organization.isAIDataAnalysisEnabled}
|
||||
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
|
||||
htmlId="ai-data-analysis-toggle"
|
||||
title={t("environments.settings.general.ai_data_analysis_enabled")}
|
||||
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
|
||||
disabled={loadingField !== null || !canEdit}
|
||||
customContainerClass="px-0"
|
||||
/>
|
||||
|
||||
{!canEdit && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+6
@@ -11,6 +11,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import packageJson from "@/package.json";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { AISettingsToggle } from "./components/AISettingsToggle";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
import { SecurityListTip } from "./components/SecurityListTip";
|
||||
@@ -60,6 +61,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
membershipRole={currentUserMembership?.role}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("environments.settings.general.ai_enabled")}
|
||||
description={t("environments.settings.general.ai_enabled_description")}>
|
||||
<AISettingsToggle organization={organization} membershipRole={currentUserMembership?.role} />
|
||||
</SettingsCard>
|
||||
<EmailCustomizationSettings
|
||||
organization={organization}
|
||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -17,15 +18,15 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
-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 { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -9,11 +10,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
const t = await getTranslate();
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new Error("Survey not found");
|
||||
throw new ResourceNotFoundError(t("common.survey"), surveyId);
|
||||
}
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
if (!project) {
|
||||
throw new Error("Workspace not found");
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const styling = getStyling(project, survey);
|
||||
|
||||
+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>;
|
||||
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
const FORMBRICKS_HOST = "https://app.formbricks.com";
|
||||
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
|
||||
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
|
||||
|
||||
interface WorkflowsPageProps {
|
||||
userEmail: string;
|
||||
organizationName: string;
|
||||
billingPlan: string;
|
||||
}
|
||||
|
||||
type Step = "prompt" | "followup" | "thankyou";
|
||||
|
||||
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>("prompt");
|
||||
const [promptValue, setPromptValue] = useState("");
|
||||
const [detailsValue, setDetailsValue] = useState("");
|
||||
const [responseId, setResponseId] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleGenerateWorkflow = async () => {
|
||||
if (promptValue.trim().length < 100 || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
surveyId: SURVEY_ID,
|
||||
finished: false,
|
||||
data: {
|
||||
workflow: promptValue.trim(),
|
||||
useremail: userEmail,
|
||||
orgname: organizationName,
|
||||
billingplan: billingPlan,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setResponseId(json.data?.id ?? null);
|
||||
}
|
||||
|
||||
setStep("followup");
|
||||
} catch {
|
||||
setStep("followup");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitFeedback = async () => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (responseId) {
|
||||
try {
|
||||
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
finished: true,
|
||||
data: {
|
||||
details: detailsValue.trim(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
setStep("thankyou");
|
||||
};
|
||||
|
||||
const handleSkipFeedback = async () => {
|
||||
if (!responseId) {
|
||||
setStep("thankyou");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
finished: true,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
|
||||
setStep("thankyou");
|
||||
};
|
||||
|
||||
if (step === "prompt") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||
<div className="w-full max-w-2xl space-y-8">
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
|
||||
<Sparkles className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
|
||||
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={promptValue}
|
||||
onChange={(e) => setPromptValue(e.target.value)}
|
||||
placeholder={t("workflows.placeholder")}
|
||||
rows={5}
|
||||
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
handleGenerateWorkflow();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span
|
||||
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
|
||||
{promptValue.trim().length} / 100
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleGenerateWorkflow}
|
||||
disabled={promptValue.trim().length < 100 || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
size="lg">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("workflows.generate_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "followup") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||
<div className="w-full max-w-2xl space-y-8">
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
|
||||
<Sparkles className="h-6 w-6 text-brand-dark" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
|
||||
{t("workflows.coming_soon_title")}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-md text-base text-slate-500">
|
||||
{t("workflows.coming_soon_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<label className="text-md mb-2 block font-medium text-slate-700">
|
||||
{t("workflows.follow_up_label")}
|
||||
</label>
|
||||
<textarea
|
||||
value={detailsValue}
|
||||
onChange={(e) => setDetailsValue(e.target.value)}
|
||||
placeholder={t("workflows.follow_up_placeholder")}
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||
/>
|
||||
<div className="mt-4 flex items-center justify-end gap-3">
|
||||
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
|
||||
{t("common.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmitFeedback}
|
||||
disabled={!detailsValue.trim() || isSubmitting}
|
||||
loading={isSubmitting}>
|
||||
{t("workflows.submit_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||
<div className="w-full max-w-md space-y-6 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
|
||||
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { WorkflowsPage } from "./components/workflows-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Workflows",
|
||||
};
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
|
||||
if (!IS_FORMBRICKS_CLOUD) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
|
||||
|
||||
return (
|
||||
<WorkflowsPage
|
||||
userEmail={user.email}
|
||||
organizationName={organization.name}
|
||||
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -51,8 +51,20 @@ vi.mock("@/lib/env", () => ({
|
||||
RECAPTCHA_SECRET_KEY: "secret-key",
|
||||
GITHUB_ID: "github-id",
|
||||
SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks",
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: false,
|
||||
}));
|
||||
vi.mock("@/lib/hash-string", () => ({
|
||||
hashString: vi.fn((s: string) => `hashed-${s}`),
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const fetchMock = vi.fn();
|
||||
@@ -199,6 +211,14 @@ describe("sendTelemetryEvents", () => {
|
||||
test("should handle telemetry send failure and apply cooldown", async () => {
|
||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: false,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
// Ensure we can acquire lock by setting last sent time far in the past
|
||||
@@ -221,6 +241,7 @@ describe("sendTelemetryEvents", () => {
|
||||
expect.objectContaining({
|
||||
error: networkError,
|
||||
message: "Network error",
|
||||
hashedLicenseKey: "hashed-test-license-key",
|
||||
}),
|
||||
"Failed to send telemetry - applying 1h cooldown"
|
||||
);
|
||||
@@ -242,6 +263,14 @@ describe("sendTelemetryEvents", () => {
|
||||
test("should skip if no organization exists", async () => {
|
||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: false,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
// Ensure we can acquire lock by setting last sent time far in the past
|
||||
@@ -276,4 +305,113 @@ describe("sendTelemetryEvents", () => {
|
||||
// This might be a bug, but we test the actual behavior
|
||||
expect(mockCacheService.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should skip telemetry when TELEMETRY_DISABLED is true and no active EE license", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: true,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
// Should return early without touching cache or sending telemetry
|
||||
expect(getCacheService).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should send telemetry when TELEMETRY_DISABLED is true but EE license is active", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: true,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
// Re-setup mocks after resetModules
|
||||
vi.mocked(getCacheService).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockCacheService as any,
|
||||
});
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true });
|
||||
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: null });
|
||||
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
|
||||
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
||||
id: "org-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
} as any);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{
|
||||
organizationCount: BigInt(1),
|
||||
userCount: BigInt(5),
|
||||
teamCount: BigInt(2),
|
||||
projectCount: BigInt(3),
|
||||
surveyCount: BigInt(10),
|
||||
inProgressSurveyCount: BigInt(4),
|
||||
completedSurveyCount: BigInt(6),
|
||||
responseCountAllTime: BigInt(100),
|
||||
responseCountSinceLastUpdate: BigInt(10),
|
||||
displayCount: BigInt(50),
|
||||
contactCount: BigInt(20),
|
||||
segmentCount: BigInt(4),
|
||||
newestResponseAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
},
|
||||
] as any);
|
||||
vi.mocked(prisma.integration.findMany).mockResolvedValue([{ type: IntegrationType.notion }] as any);
|
||||
vi.mocked(prisma.account.findMany).mockResolvedValue([{ provider: "github" }] as any);
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
// EE license active — telemetry should bypass TELEMETRY_DISABLED and send
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should unconditionally skip when E2E_TESTING is true even with active EE license", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: true,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: false,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
// E2E_TESTING is a hard skip — no EE bypass, no cache, no fetch
|
||||
expect(getCacheService).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should unconditionally skip when IS_DEVELOPMENT is true", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
TELEMETRY_DISABLED: false,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
expect(getCacheService).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,11 @@ import { IntegrationType } from "@prisma/client";
|
||||
import { createCacheKey, getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { E2E_TESTING, IS_DEVELOPMENT, TELEMETRY_DISABLED } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { getInstanceInfo } from "@/lib/instance";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import packageJson from "@/package.json";
|
||||
|
||||
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -24,8 +27,31 @@ let nextTelemetryCheck = 0;
|
||||
* 2. Redis check (shared across instances, persists across restarts)
|
||||
* 3. Distributed lock (prevents concurrent execution in multi-instance deployments)
|
||||
*/
|
||||
// Hashed license key for log context — allows correlating log entries to a specific license
|
||||
// without exposing the raw key. Computed once at module load.
|
||||
const hashedLicenseKey = env.ENTERPRISE_LICENSE_KEY ? hashString(env.ENTERPRISE_LICENSE_KEY) : null;
|
||||
|
||||
/**
|
||||
* Returns true if telemetry is disabled via env var AND there is no active EE license.
|
||||
* EE customers cannot opt out — telemetry is always enforced for license compliance.
|
||||
*/
|
||||
const isTelemetryDisabledForCE = async (): Promise<boolean> => {
|
||||
if (!TELEMETRY_DISABLED) return false;
|
||||
const license = await getEnterpriseLicense();
|
||||
return !license.active;
|
||||
};
|
||||
|
||||
export const sendTelemetryEvents = async () => {
|
||||
try {
|
||||
// ============================================================
|
||||
// CHECK 0: Non-Production Hard Skip
|
||||
// ============================================================
|
||||
// Purpose: Unconditionally skip telemetry in dev and test/CI environments.
|
||||
// No EE bypass — these are internal flags, not customer-facing.
|
||||
if (E2E_TESTING || IS_DEVELOPMENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// ============================================================
|
||||
@@ -39,7 +65,18 @@ export const sendTelemetryEvents = async () => {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHECK 2: Redis Check (Shared State)
|
||||
// CHECK 2: Telemetry Disabled Check
|
||||
// ============================================================
|
||||
// Purpose: Allow CE self-hosters to opt out of telemetry via env var.
|
||||
// EE bypass: If an active Enterprise License is detected, telemetry is always sent
|
||||
// regardless of the TELEMETRY_DISABLED setting to enforce license compliance.
|
||||
// Placed after in-memory check to avoid calling getEnterpriseLicense() on every invocation.
|
||||
if (await isTelemetryDisabledForCE()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHECK 3: Redis Check (Shared State)
|
||||
// ============================================================
|
||||
// Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster).
|
||||
// This persists across restarts and works in multi-instance deployments.
|
||||
@@ -66,7 +103,7 @@ export const sendTelemetryEvents = async () => {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHECK 3: Distributed Lock (Prevent Concurrent Execution)
|
||||
// CHECK 4: Distributed Lock (Prevent Concurrent Execution)
|
||||
// ============================================================
|
||||
// Purpose: Ensure only ONE instance executes telemetry at a time in a cluster.
|
||||
// How it works:
|
||||
@@ -100,7 +137,7 @@ export const sendTelemetryEvents = async () => {
|
||||
// Log as warning since telemetry is non-essential
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
logger.warn(
|
||||
{ error: e, message: errorMessage, lastSent, now },
|
||||
{ error: e, message: errorMessage, lastSent, now, hashedLicenseKey },
|
||||
"Failed to send telemetry - applying 1h cooldown"
|
||||
);
|
||||
|
||||
@@ -118,7 +155,7 @@ export const sendTelemetryEvents = async () => {
|
||||
// Log as warning since telemetry is non-essential functionality
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(
|
||||
{ error, message: errorMessage, timestamp: Date.now() },
|
||||
{ error, message: errorMessage, timestamp: Date.now(), hashedLicenseKey },
|
||||
"Unexpected error in sendTelemetryEvents wrapper - telemetry check skipped"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
const nextAuth = vi.fn(() => nextAuthHandler);
|
||||
|
||||
return {
|
||||
nextAuth,
|
||||
nextAuthHandler,
|
||||
baseSignIn: vi.fn(async () => true),
|
||||
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
|
||||
baseEventSignIn: vi.fn(),
|
||||
queueAuditEventBackground: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
default: mocks.nextAuth,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: mocks.captureException,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
error: mocks.loggerError,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {
|
||||
callbacks: {
|
||||
signIn: mocks.baseSignIn,
|
||||
session: mocks.baseSession,
|
||||
},
|
||||
events: {
|
||||
signIn: mocks.baseEventSignIn,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEventBackground: mocks.queueAuditEventBackground,
|
||||
}));
|
||||
|
||||
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
|
||||
const request = new Request("http://localhost/api/auth/signin", {
|
||||
headers: { "x-request-id": requestId },
|
||||
});
|
||||
|
||||
await GET(request, {} as any);
|
||||
|
||||
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
|
||||
|
||||
return mocks.nextAuth.mock.calls[0][0];
|
||||
};
|
||||
|
||||
describe("auth route audit logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
|
||||
const authOptions = await getWrappedAuthOptions();
|
||||
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
|
||||
const account = { provider: "keycloak" };
|
||||
|
||||
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
|
||||
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
|
||||
|
||||
await authOptions.events.signIn({ user, account, isNewUser: false });
|
||||
|
||||
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
|
||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "signedIn",
|
||||
targetType: "user",
|
||||
userId: "user_1",
|
||||
targetId: "user_1",
|
||||
organizationId: "unknown",
|
||||
status: "success",
|
||||
userType: "user",
|
||||
newObject: expect.objectContaining({
|
||||
email: "user@example.com",
|
||||
authMethod: "sso",
|
||||
provider: "keycloak",
|
||||
sessionStrategy: "database",
|
||||
isNewUser: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
|
||||
const error = new Error("Access denied");
|
||||
mocks.baseSignIn.mockRejectedValueOnce(error);
|
||||
|
||||
const authOptions = await getWrappedAuthOptions("req-failure");
|
||||
const user = { id: "user_2", email: "user2@example.com" };
|
||||
const account = { provider: "credentials" };
|
||||
|
||||
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
|
||||
|
||||
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "signedIn",
|
||||
targetType: "user",
|
||||
userId: "user_2",
|
||||
targetId: "user_2",
|
||||
organizationId: "unknown",
|
||||
status: "failure",
|
||||
userType: "user",
|
||||
eventId: "req-failure",
|
||||
newObject: expect.objectContaining({
|
||||
email: "user2@example.com",
|
||||
authMethod: "password",
|
||||
provider: "credentials",
|
||||
errorMessage: "Access denied",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,26 @@ import { logger } from "@formbricks/logger";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
|
||||
export const fetchCache = "force-no-store";
|
||||
|
||||
const getAuthMethod = (account: Account | null) => {
|
||||
if (account?.provider === "credentials") {
|
||||
return "password";
|
||||
}
|
||||
|
||||
if (account?.provider === "token") {
|
||||
return "email_verification";
|
||||
}
|
||||
|
||||
if (account?.provider) {
|
||||
return "sso";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const handler = async (req: Request, ctx: any) => {
|
||||
const eventId = req.headers.get("x-request-id") ?? undefined;
|
||||
|
||||
@@ -17,44 +33,6 @@ const handler = async (req: Request, ctx: any) => {
|
||||
...baseAuthOptions,
|
||||
callbacks: {
|
||||
...baseAuthOptions.callbacks,
|
||||
async jwt(params: any) {
|
||||
let result: any = params.token;
|
||||
let error: any = undefined;
|
||||
|
||||
try {
|
||||
if (baseAuthOptions.callbacks?.jwt) {
|
||||
result = await baseAuthOptions.callbacks.jwt(params);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
logger.withContext({ eventId, err }).error("JWT callback failed");
|
||||
|
||||
if (SENTRY_DSN && IS_PRODUCTION) {
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Audit JWT operations (token refresh, updates)
|
||||
if (params.trigger && params.token?.profile?.id) {
|
||||
const status: TAuditStatus = error ? "failure" : "success";
|
||||
const auditLog = {
|
||||
action: "jwtTokenCreated" as const,
|
||||
targetType: "user" as const,
|
||||
userId: params.token.profile.id,
|
||||
targetId: params.token.profile.id,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status,
|
||||
userType: "user" as const,
|
||||
newObject: { trigger: params.trigger, tokenType: "jwt" },
|
||||
...(error ? { eventId } : {}),
|
||||
};
|
||||
|
||||
queueAuditEventBackground(auditLog);
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
},
|
||||
async session(params: any) {
|
||||
let result: any = params.session;
|
||||
let error: any = undefined;
|
||||
@@ -90,7 +68,7 @@ const handler = async (req: Request, ctx: any) => {
|
||||
}) {
|
||||
let result: boolean | string = true;
|
||||
let error: any = undefined;
|
||||
let authMethod = "unknown";
|
||||
const authMethod = getAuthMethod(account);
|
||||
|
||||
try {
|
||||
if (baseAuthOptions.callbacks?.signIn) {
|
||||
@@ -102,15 +80,6 @@ const handler = async (req: Request, ctx: any) => {
|
||||
credentials,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine authentication method for more detailed logging
|
||||
if (account?.provider === "credentials") {
|
||||
authMethod = "password";
|
||||
} else if (account?.provider === "token") {
|
||||
authMethod = "email_verification";
|
||||
} else if (account?.provider && account.provider !== "credentials") {
|
||||
authMethod = "sso";
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
result = false;
|
||||
@@ -122,30 +91,60 @@ const handler = async (req: Request, ctx: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const status: TAuditStatus = result === false ? "failure" : "success";
|
||||
const auditLog = {
|
||||
action: "signedIn" as const,
|
||||
targetType: "user" as const,
|
||||
userId: user?.id ?? UNKNOWN_DATA,
|
||||
targetId: user?.id ?? UNKNOWN_DATA,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status,
|
||||
userType: "user" as const,
|
||||
newObject: {
|
||||
...user,
|
||||
authMethod,
|
||||
provider: account?.provider,
|
||||
...(error ? { errorMessage: error.message } : {}),
|
||||
},
|
||||
...(status === "failure" ? { eventId } : {}),
|
||||
};
|
||||
|
||||
queueAuditEventBackground(auditLog);
|
||||
if (result === false) {
|
||||
queueAuditEventBackground({
|
||||
action: "signedIn",
|
||||
targetType: "user",
|
||||
userId: user?.id ?? UNKNOWN_DATA,
|
||||
targetId: user?.id ?? UNKNOWN_DATA,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status: "failure",
|
||||
userType: "user",
|
||||
newObject: {
|
||||
...user,
|
||||
authMethod,
|
||||
provider: account?.provider,
|
||||
...(error instanceof Error ? { errorMessage: error.message } : {}),
|
||||
},
|
||||
eventId,
|
||||
});
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
events: {
|
||||
...baseAuthOptions.events,
|
||||
async signIn({ user, account, isNewUser }: any) {
|
||||
try {
|
||||
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
|
||||
} catch (err) {
|
||||
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
|
||||
|
||||
if (SENTRY_DSN && IS_PRODUCTION) {
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
}
|
||||
|
||||
queueAuditEventBackground({
|
||||
action: "signedIn",
|
||||
targetType: "user",
|
||||
userId: user?.id ?? UNKNOWN_DATA,
|
||||
targetId: user?.id ?? UNKNOWN_DATA,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
status: "success",
|
||||
userType: "user",
|
||||
newObject: {
|
||||
...user,
|
||||
authMethod: getAuthMethod(account),
|
||||
provider: account?.provider,
|
||||
sessionStrategy: "database",
|
||||
isNewUser: isNewUser ?? false,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return NextAuth(authOptions)(req, ctx);
|
||||
|
||||
@@ -76,7 +76,8 @@ const mockOrganization: TOrganization = {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
};
|
||||
|
||||
const mockSurveys: TSurvey[] = [
|
||||
|
||||
@@ -86,9 +86,11 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
logger.error(
|
||||
{
|
||||
error: err,
|
||||
error,
|
||||
url: req.url,
|
||||
environmentId: params.environmentId,
|
||||
},
|
||||
@@ -96,9 +98,10 @@ export const GET = withV1ApiWrapper({
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(
|
||||
err instanceof Error ? err.message : "Unknown error occurred",
|
||||
"An error occurred while processing your request.",
|
||||
true
|
||||
),
|
||||
error,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
+488
@@ -0,0 +1,488 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { putResponseHandler } from "./put-response-handler";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
|
||||
getResponse: vi.fn(),
|
||||
getSurvey: vi.fn(),
|
||||
getValidatedResponseUpdateInput: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
sendToPipeline: vi.fn(),
|
||||
updateResponseWithQuotaEvaluation: vi.fn(),
|
||||
validateFileUploads: vi.fn(),
|
||||
validateOtherOptionLengthForMultipleChoice: vi.fn(),
|
||||
validateResponseData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: mocks.loggerError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/pipelines", () => ({
|
||||
sendToPipeline: mocks.sendToPipeline,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/service", () => ({
|
||||
getResponse: mocks.getResponse,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: mocks.getSurvey,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/validation", () => ({
|
||||
formatValidationErrorsForV1Api: mocks.formatValidationErrorsForV1Api,
|
||||
validateResponseData: mocks.validateResponseData,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/element", () => ({
|
||||
validateOtherOptionLengthForMultipleChoice: mocks.validateOtherOptionLengthForMultipleChoice,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/storage/utils", () => ({
|
||||
validateFileUploads: mocks.validateFileUploads,
|
||||
}));
|
||||
|
||||
vi.mock("./response", () => ({
|
||||
updateResponseWithQuotaEvaluation: mocks.updateResponseWithQuotaEvaluation,
|
||||
}));
|
||||
|
||||
vi.mock("./validated-response-update-input", () => ({
|
||||
getValidatedResponseUpdateInput: mocks.getValidatedResponseUpdateInput,
|
||||
}));
|
||||
|
||||
const environmentId = "environment_a";
|
||||
const responseId = "response_123";
|
||||
const surveyId = "survey_123";
|
||||
|
||||
const createRequest = () =>
|
||||
new Request(`https://api.test/api/v1/client/${environmentId}/responses/${responseId}`, {
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
const createHandlerParams = (params?: Partial<{ environmentId: string; responseId: string }>) =>
|
||||
({
|
||||
req: createRequest(),
|
||||
props: {
|
||||
params: Promise.resolve({
|
||||
environmentId,
|
||||
responseId,
|
||||
...params,
|
||||
}),
|
||||
},
|
||||
}) as never;
|
||||
|
||||
const getBaseResponseUpdateInput = () => ({
|
||||
data: {
|
||||
q1: "updated-answer",
|
||||
},
|
||||
language: "en",
|
||||
});
|
||||
|
||||
const getBaseExistingResponse = () =>
|
||||
({
|
||||
id: responseId,
|
||||
surveyId,
|
||||
data: {
|
||||
q0: "existing-answer",
|
||||
},
|
||||
finished: false,
|
||||
language: "en",
|
||||
}) as const;
|
||||
|
||||
const getBaseSurvey = () =>
|
||||
({
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
blocks: [],
|
||||
questions: [],
|
||||
}) as const;
|
||||
|
||||
const getBaseUpdatedResponse = () =>
|
||||
({
|
||||
id: responseId,
|
||||
surveyId,
|
||||
data: {
|
||||
q0: "existing-answer",
|
||||
q1: "updated-answer",
|
||||
},
|
||||
finished: false,
|
||||
quotaFull: undefined,
|
||||
}) as const;
|
||||
|
||||
describe("putResponseHandler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.getValidatedResponseUpdateInput.mockResolvedValue({
|
||||
responseUpdateInput: getBaseResponseUpdateInput(),
|
||||
});
|
||||
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
|
||||
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
|
||||
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
|
||||
mocks.validateFileUploads.mockReturnValue(true);
|
||||
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
|
||||
mocks.validateResponseData.mockReturnValue(null);
|
||||
});
|
||||
|
||||
test("returns a bad request response when the response id is missing", async () => {
|
||||
const result = await putResponseHandler(createHandlerParams({ responseId: "" }));
|
||||
|
||||
expect(result.response.status).toBe(400);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "bad_request",
|
||||
message: "Response ID is missing",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.getValidatedResponseUpdateInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns the validation response from the parsed request input", async () => {
|
||||
const validationResponse = responses.badRequestResponse(
|
||||
"Malformed JSON in request body",
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
mocks.getValidatedResponseUpdateInput.mockResolvedValue({
|
||||
response: validationResponse,
|
||||
});
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response).toBe(validationResponse);
|
||||
expect(mocks.getResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns not found when the response does not exist", async () => {
|
||||
mocks.getResponse.mockResolvedValue(null);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(404);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "not_found",
|
||||
message: "Response not found",
|
||||
details: {
|
||||
resource_id: responseId,
|
||||
resource_type: "Response",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps resource lookup errors to a not found response", async () => {
|
||||
mocks.getResponse.mockRejectedValue(new ResourceNotFoundError("Response", responseId));
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(404);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "not_found",
|
||||
message: "Response not found",
|
||||
details: {
|
||||
resource_id: responseId,
|
||||
resource_type: "Response",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps invalid lookup input errors to a bad request response", async () => {
|
||||
mocks.getResponse.mockRejectedValue(new InvalidInputError("Invalid response id"));
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(400);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "bad_request",
|
||||
message: "Invalid response id",
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps database lookup errors to a reported internal server error", async () => {
|
||||
const error = new DatabaseError("Lookup failed");
|
||||
mocks.getResponse.mockRejectedValue(error);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.error).toBe(error);
|
||||
expect(result.response.status).toBe(500);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Lookup failed",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.loggerError).toHaveBeenCalledWith(
|
||||
{
|
||||
error,
|
||||
url: createRequest().url,
|
||||
},
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps unknown lookup failures to a generic internal server error", async () => {
|
||||
const error = new Error("boom");
|
||||
mocks.getResponse.mockRejectedValue(error);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.error).toBe(error);
|
||||
expect(result.response.status).toBe(500);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Unknown error occurred",
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects updates when the response survey does not belong to the requested environment", async () => {
|
||||
mocks.getSurvey.mockResolvedValue({
|
||||
...getBaseSurvey(),
|
||||
environmentId: "different_environment",
|
||||
});
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(404);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "not_found",
|
||||
message: "Response not found",
|
||||
details: {
|
||||
resource_id: responseId,
|
||||
resource_type: "Response",
|
||||
},
|
||||
});
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
||||
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects updates when the response is already finished", async () => {
|
||||
mocks.getResponse.mockResolvedValue({
|
||||
...getBaseExistingResponse(),
|
||||
finished: true,
|
||||
});
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(400);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "bad_request",
|
||||
message: "Response is already finished",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects invalid file upload updates", async () => {
|
||||
mocks.validateFileUploads.mockReturnValue(false);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(400);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "bad_request",
|
||||
message: "Invalid file upload response",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects updates when an other-option response exceeds the character limit", async () => {
|
||||
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue("question_123");
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(400);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "bad_request",
|
||||
message: "Response exceeds character limit",
|
||||
details: {
|
||||
questionId: "question_123",
|
||||
},
|
||||
});
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns validation details when merged response data is invalid", async () => {
|
||||
mocks.validateResponseData.mockReturnValue([{ field: "q1", message: "Required" }]);
|
||||
mocks.formatValidationErrorsForV1Api.mockReturnValue({
|
||||
q1: "Required",
|
||||
});
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(400);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "bad_request",
|
||||
message: "Validation failed",
|
||||
details: {
|
||||
q1: "Required",
|
||||
},
|
||||
});
|
||||
expect(mocks.formatValidationErrorsForV1Api).toHaveBeenCalledWith([{ field: "q1", message: "Required" }]);
|
||||
});
|
||||
|
||||
test("returns not found when the response disappears during update", async () => {
|
||||
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(
|
||||
new ResourceNotFoundError("Response", responseId)
|
||||
);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(404);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "not_found",
|
||||
message: "Response not found",
|
||||
details: {
|
||||
resource_id: responseId,
|
||||
resource_type: "Response",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a bad request response for invalid update input during persistence", async () => {
|
||||
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(
|
||||
new InvalidInputError("Response update payload is invalid")
|
||||
);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(400);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "bad_request",
|
||||
message: "Response update payload is invalid",
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a reported internal server error for database update failures", async () => {
|
||||
const error = new DatabaseError("Update failed");
|
||||
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(error);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.error).toBe(error);
|
||||
expect(result.response.status).toBe(500);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Update failed",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.loggerError).toHaveBeenCalledWith(
|
||||
{
|
||||
error,
|
||||
url: createRequest().url,
|
||||
},
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns a generic internal server error for unexpected update failures", async () => {
|
||||
const error = new Error("Unexpected persistence failure");
|
||||
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(error);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.error).toBe(error);
|
||||
expect(result.response.status).toBe(500);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Something went wrong",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.loggerError).toHaveBeenCalledWith(
|
||||
{
|
||||
error,
|
||||
url: createRequest().url,
|
||||
},
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns a success payload and emits a responseUpdated pipeline event", async () => {
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(200);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
data: {
|
||||
id: responseId,
|
||||
quotaFull: false,
|
||||
},
|
||||
});
|
||||
expect(mocks.sendToPipeline).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.sendToPipeline).toHaveBeenCalledWith({
|
||||
event: "responseUpdated",
|
||||
environmentId,
|
||||
surveyId,
|
||||
response: {
|
||||
id: responseId,
|
||||
surveyId,
|
||||
data: {
|
||||
q0: "existing-answer",
|
||||
q1: "updated-answer",
|
||||
},
|
||||
finished: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("emits both pipeline events and includes quota metadata when the response finishes", async () => {
|
||||
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue({
|
||||
...getBaseUpdatedResponse(),
|
||||
finished: true,
|
||||
quotaFull: {
|
||||
id: "quota_123",
|
||||
action: "endSurvey",
|
||||
endingCardId: "ending_card_123",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
expect(result.response.status).toBe(200);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
data: {
|
||||
id: responseId,
|
||||
quotaFull: true,
|
||||
quota: {
|
||||
id: "quota_123",
|
||||
action: "endSurvey",
|
||||
endingCardId: "ending_card_123",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mocks.sendToPipeline).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.sendToPipeline).toHaveBeenNthCalledWith(1, {
|
||||
event: "responseUpdated",
|
||||
environmentId,
|
||||
surveyId,
|
||||
response: {
|
||||
id: responseId,
|
||||
surveyId,
|
||||
data: {
|
||||
q0: "existing-answer",
|
||||
q1: "updated-answer",
|
||||
},
|
||||
finished: true,
|
||||
},
|
||||
});
|
||||
expect(mocks.sendToPipeline).toHaveBeenNthCalledWith(2, {
|
||||
event: "responseFinished",
|
||||
environmentId,
|
||||
surveyId,
|
||||
response: {
|
||||
id: responseId,
|
||||
surveyId,
|
||||
data: {
|
||||
q0: "existing-answer",
|
||||
q1: "updated-answer",
|
||||
},
|
||||
finished: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { THandlerParams } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { updateResponseWithQuotaEvaluation } from "./response";
|
||||
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
|
||||
|
||||
type TRouteResult = {
|
||||
response: Response;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
type TExistingResponseResult = { existingResponse: TResponse } | TRouteResult;
|
||||
type TSurveyResult = { survey: TSurvey } | TRouteResult;
|
||||
type TUpdatedResponseResult =
|
||||
| { updatedResponse: Awaited<ReturnType<typeof updateResponseWithQuotaEvaluation>> }
|
||||
| TRouteResult;
|
||||
|
||||
export type TPutRouteParams = {
|
||||
params: Promise<{
|
||||
environmentId: string;
|
||||
responseId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const handleDatabaseError = (
|
||||
error: Error,
|
||||
url: string,
|
||||
endpoint: string,
|
||||
responseId: string
|
||||
): TRouteResult => {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return { response: responses.notFoundResponse("Response", responseId, true) };
|
||||
}
|
||||
if (error instanceof InvalidInputError) {
|
||||
return { response: responses.badRequestResponse(error.message, undefined, true) };
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error({ error, url }, `Error in ${endpoint}`);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error.message, true),
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Unknown error occurred", true),
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
const validateResponse = (
|
||||
response: TResponse,
|
||||
survey: TSurvey,
|
||||
responseUpdateInput: TResponseUpdateInput
|
||||
) => {
|
||||
const mergedData = {
|
||||
...response.data,
|
||||
...responseUpdateInput.data,
|
||||
};
|
||||
|
||||
const validationErrors = validateResponseData(
|
||||
survey.blocks,
|
||||
mergedData,
|
||||
responseUpdateInput.language ?? response.language ?? "en",
|
||||
survey.questions
|
||||
);
|
||||
|
||||
if (validationErrors) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Validation failed",
|
||||
formatValidationErrorsForV1Api(validationErrors),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getExistingResponse = async (req: Request, responseId: string): Promise<TExistingResponseResult> => {
|
||||
try {
|
||||
const existingResponse = await getResponse(responseId);
|
||||
|
||||
return existingResponse
|
||||
? { existingResponse }
|
||||
: { response: responses.notFoundResponse("Response", responseId, true) };
|
||||
} catch (error) {
|
||||
return handleDatabaseError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
req.url,
|
||||
"PUT /api/v1/client/[environmentId]/responses/[responseId]",
|
||||
responseId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getSurveyForResponse = async (
|
||||
req: Request,
|
||||
responseId: string,
|
||||
surveyId: string
|
||||
): Promise<TSurveyResult> => {
|
||||
try {
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
return survey ? { survey } : { response: responses.notFoundResponse("Survey", surveyId, true) };
|
||||
} catch (error) {
|
||||
return handleDatabaseError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
req.url,
|
||||
"PUT /api/v1/client/[environmentId]/responses/[responseId]",
|
||||
responseId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateUpdateRequest = (
|
||||
existingResponse: TResponse,
|
||||
survey: TSurvey,
|
||||
responseUpdateInput: TResponseUpdateInput
|
||||
): TRouteResult | undefined => {
|
||||
if (existingResponse.finished) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Response is already finished", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseUpdateInput.data, survey.questions)) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||
responseData: responseUpdateInput.data,
|
||||
surveyQuestions: survey.questions as unknown as TSurveyElement[],
|
||||
responseLanguage: responseUpdateInput.language,
|
||||
});
|
||||
|
||||
if (otherResponseInvalidQuestionId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
`Response exceeds character limit`,
|
||||
{
|
||||
questionId: otherResponseInvalidQuestionId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return validateResponse(existingResponse, survey, responseUpdateInput);
|
||||
};
|
||||
|
||||
const getUpdatedResponse = async (
|
||||
req: Request,
|
||||
responseId: string,
|
||||
responseUpdateInput: TResponseUpdateInput
|
||||
): Promise<TUpdatedResponseResult> => {
|
||||
try {
|
||||
const updatedResponse = await updateResponseWithQuotaEvaluation(responseId, responseUpdateInput);
|
||||
return { updatedResponse };
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Response", responseId, true),
|
||||
};
|
||||
}
|
||||
if (error instanceof InvalidInputError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error(
|
||||
{ error, url: req.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error.message),
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
const unexpectedError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
logger.error(
|
||||
{ error: unexpectedError, url: req.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Something went wrong"),
|
||||
error: unexpectedError,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const putResponseHandler = async ({
|
||||
req,
|
||||
props,
|
||||
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
|
||||
const params = await props.params;
|
||||
const { environmentId, responseId } = params;
|
||||
|
||||
if (!responseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Response ID is missing", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
const validatedUpdateInput = await getValidatedResponseUpdateInput(req);
|
||||
if ("response" in validatedUpdateInput) {
|
||||
return validatedUpdateInput;
|
||||
}
|
||||
const { responseUpdateInput } = validatedUpdateInput;
|
||||
|
||||
const existingResponseResult = await getExistingResponse(req, responseId);
|
||||
if ("response" in existingResponseResult) {
|
||||
return existingResponseResult;
|
||||
}
|
||||
const { existingResponse } = existingResponseResult;
|
||||
|
||||
const surveyResult = await getSurveyForResponse(req, responseId, existingResponse.surveyId);
|
||||
if ("response" in surveyResult) {
|
||||
return surveyResult;
|
||||
}
|
||||
const { survey } = surveyResult;
|
||||
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Response", responseId, true),
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput);
|
||||
if (validationResult) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
const updatedResponseResult = await getUpdatedResponse(req, responseId, responseUpdateInput);
|
||||
if ("response" in updatedResponseResult) {
|
||||
return updatedResponseResult;
|
||||
}
|
||||
const { updatedResponse } = updatedResponseResult;
|
||||
|
||||
const { quotaFull, ...responseData } = updatedResponse;
|
||||
|
||||
sendToPipeline({
|
||||
event: "responseUpdated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: responseData,
|
||||
});
|
||||
|
||||
if (updatedResponse.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: responseData,
|
||||
});
|
||||
}
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
id: responseData.id,
|
||||
...quotaObj,
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse(responseDataWithQuota, true),
|
||||
};
|
||||
};
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
|
||||
|
||||
describe("getValidatedResponseUpdateInput", () => {
|
||||
test("returns a bad request response for malformed JSON", async () => {
|
||||
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{invalid-json",
|
||||
});
|
||||
|
||||
const result = await getValidatedResponseUpdateInput(request);
|
||||
|
||||
expect("response" in result).toBe(true);
|
||||
|
||||
if (!("response" in result)) {
|
||||
throw new Error("Expected a response result");
|
||||
}
|
||||
|
||||
expect(result.response.status).toBe(400);
|
||||
await expect(result.response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
code: "bad_request",
|
||||
message: "Malformed JSON in request body",
|
||||
details: {
|
||||
error: expect.any(String),
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns parsed response update input for valid JSON", async () => {
|
||||
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
finished: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await getValidatedResponseUpdateInput(request);
|
||||
|
||||
expect(result).toEqual({
|
||||
responseUpdateInput: {
|
||||
finished: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a bad request response for schema-invalid JSON", async () => {
|
||||
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
finished: "not-boolean",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await getValidatedResponseUpdateInput(request);
|
||||
|
||||
expect("response" in result).toBe(true);
|
||||
|
||||
if (!("response" in result)) {
|
||||
throw new Error("Expected a response result");
|
||||
}
|
||||
|
||||
expect(result.response.status).toBe(400);
|
||||
await expect(result.response.json()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
code: "bad_request",
|
||||
message: "Fields are missing or incorrectly formatted",
|
||||
details: expect.objectContaining({
|
||||
finished: expect.any(String),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import {
|
||||
TParseAndValidateJsonBodyResult,
|
||||
parseAndValidateJsonBody,
|
||||
} from "@/app/lib/api/parse-and-validate-json-body";
|
||||
|
||||
export type TValidatedResponseUpdateInputResult =
|
||||
| { response: Response }
|
||||
| { responseUpdateInput: TResponseUpdateInput };
|
||||
|
||||
export const getValidatedResponseUpdateInput = async (
|
||||
req: Request
|
||||
): Promise<TValidatedResponseUpdateInputResult> => {
|
||||
const validatedInput: TParseAndValidateJsonBodyResult<TResponseUpdateInput> =
|
||||
await parseAndValidateJsonBody({
|
||||
request: req,
|
||||
schema: ZResponseUpdateInput,
|
||||
malformedJsonMessage: "Malformed JSON in request body",
|
||||
});
|
||||
|
||||
if ("response" in validatedInput) {
|
||||
return {
|
||||
response: validatedInput.response,
|
||||
};
|
||||
}
|
||||
|
||||
return { responseUpdateInput: validatedInput.data };
|
||||
};
|
||||
@@ -1,235 +1,11 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
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 { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { putResponseHandler } from "./lib/put-response-handler";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse("Response", responseId, true);
|
||||
}
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message, undefined, true);
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error({ error, url }, `Error in ${endpoint}`);
|
||||
return responses.internalServerErrorResponse(error.message, true);
|
||||
}
|
||||
return responses.internalServerErrorResponse("Unknown error occurred", true);
|
||||
};
|
||||
|
||||
const validateResponse = (
|
||||
response: TResponse,
|
||||
survey: TSurvey,
|
||||
responseUpdateInput: TResponseUpdateInput
|
||||
) => {
|
||||
// Validate response data against validation rules
|
||||
const mergedData = {
|
||||
...response.data,
|
||||
...responseUpdateInput.data,
|
||||
};
|
||||
|
||||
const validationErrors = validateResponseData(
|
||||
survey.blocks,
|
||||
mergedData,
|
||||
responseUpdateInput.language ?? response.language ?? "en",
|
||||
survey.questions
|
||||
);
|
||||
|
||||
if (validationErrors) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Validation failed",
|
||||
formatValidationErrorsForV1Api(validationErrors),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ responseId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
const { responseId } = params;
|
||||
|
||||
if (!responseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Response ID is missing", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
const responseUpdate = await req.json();
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await getResponse(responseId);
|
||||
} catch (error) {
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return {
|
||||
response: handleDatabaseError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
req.url,
|
||||
endpoint,
|
||||
responseId
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Response", responseId, true),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.finished) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Response is already finished", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
// get survey to get environmentId
|
||||
let survey;
|
||||
try {
|
||||
survey = await getSurvey(response.surveyId);
|
||||
} catch (error) {
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return {
|
||||
response: handleDatabaseError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
req.url,
|
||||
endpoint,
|
||||
responseId
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!survey) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", response.surveyId, true),
|
||||
};
|
||||
}
|
||||
|
||||
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
// Validate response data for "other" options exceeding character limit
|
||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||
responseData: inputValidation.data.data,
|
||||
surveyQuestions: survey.questions as unknown as TSurveyElement[],
|
||||
responseLanguage: inputValidation.data.language,
|
||||
});
|
||||
|
||||
if (otherResponseInvalidQuestionId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
`Response exceeds character limit`,
|
||||
{
|
||||
questionId: otherResponseInvalidQuestionId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = validateResponse(response, survey, inputValidation.data);
|
||||
if (validationResult) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
// update response with quota evaluation
|
||||
let updatedResponse;
|
||||
try {
|
||||
updatedResponse = await updateResponseWithQuotaEvaluation(responseId, inputValidation.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Response", responseId, true),
|
||||
};
|
||||
}
|
||||
if (error instanceof InvalidInputError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error(
|
||||
{ error, url: req.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(error.message),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ error, url: req.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Something went wrong"),
|
||||
};
|
||||
}
|
||||
|
||||
const { quotaFull, ...responseData } = updatedResponse;
|
||||
|
||||
// send response update to pipeline
|
||||
// don't await to not block the response
|
||||
sendToPipeline({
|
||||
event: "responseUpdated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: responseData,
|
||||
});
|
||||
|
||||
if (updatedResponse.finished) {
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: responseData,
|
||||
});
|
||||
}
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
id: responseData.id,
|
||||
...quotaObj,
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse(responseDataWithQuota, true),
|
||||
};
|
||||
},
|
||||
handler: putResponseHandler,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage";
|
||||
import { ZUploadPrivateFileRequest } from "@formbricks/types/storage";
|
||||
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
|
||||
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 { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -30,33 +30,27 @@ export const POST = withV1ApiWrapper({
|
||||
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
const { environmentId } = params;
|
||||
let jsonInput: TUploadPrivateFileRequest;
|
||||
|
||||
try {
|
||||
jsonInput = await req.json();
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||
return {
|
||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||
};
|
||||
}
|
||||
|
||||
const parsedInputResult = ZUploadPrivateFileRequest.safeParse({
|
||||
...jsonInput,
|
||||
environmentId,
|
||||
const parsedInputResult = await parseAndValidateJsonBody({
|
||||
request: req,
|
||||
schema: ZUploadPrivateFileRequest,
|
||||
buildInput: (jsonInput) => ({
|
||||
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
|
||||
environmentId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!parsedInputResult.success) {
|
||||
const errorDetails = transformErrorToDetails(parsedInputResult.error);
|
||||
|
||||
logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted");
|
||||
if ("response" in parsedInputResult) {
|
||||
if (parsedInputResult.issue === "invalid_json") {
|
||||
logger.error({ error: parsedInputResult.details, url: req.url }, "Error parsing JSON input");
|
||||
} else {
|
||||
logger.error(
|
||||
{ error: parsedInputResult.details, url: req.url },
|
||||
"Fields are missing or incorrectly formatted"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
errorDetails,
|
||||
true
|
||||
),
|
||||
response: parsedInputResult.response,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,9 +99,14 @@ export const POST = withV1ApiWrapper({
|
||||
if (!signedUrlResponse.ok) {
|
||||
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
|
||||
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
|
||||
return {
|
||||
response: errorResponse,
|
||||
};
|
||||
return errorResponse.status >= 500
|
||||
? {
|
||||
response: errorResponse,
|
||||
error: signedUrlResponse.error,
|
||||
}
|
||||
: {
|
||||
response: errorResponse,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,7 +49,8 @@ const mockOrganization: TOrganization = {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
};
|
||||
|
||||
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createDisplay: vi.fn(),
|
||||
getIsContactsEnabled: vi.fn(),
|
||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
||||
reportApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./lib/display", () => ({
|
||||
createDisplay: mocks.createDisplay,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsContactsEnabled: mocks.getIsContactsEnabled,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/api-error-reporter", () => ({
|
||||
reportApiError: mocks.reportApiError,
|
||||
}));
|
||||
|
||||
const environmentId = "cld1234567890abcdef123456";
|
||||
const surveyId = "clg123456789012345678901234";
|
||||
|
||||
describe("api/v2 client displays route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
|
||||
mocks.getIsContactsEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("returns a v2 bad request response for malformed JSON without reporting an internal error", async () => {
|
||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{",
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ environmentId }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "bad_request",
|
||||
message: "Invalid JSON in request body",
|
||||
})
|
||||
);
|
||||
expect(mocks.createDisplay).not.toHaveBeenCalled();
|
||||
expect(mocks.reportApiError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
|
||||
const underlyingError = new Error("display persistence failed");
|
||||
mocks.createDisplay.mockRejectedValue(underlyingError);
|
||||
|
||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
surveyId,
|
||||
}),
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ environmentId }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Something went wrong. Please try again.",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
||||
request,
|
||||
status: 500,
|
||||
error: underlyingError,
|
||||
});
|
||||
});
|
||||
|
||||
test("reports unexpected contact-license lookup failures with the same generic public response", async () => {
|
||||
const underlyingError = new Error("license lookup failed");
|
||||
mocks.getOrganizationIdFromEnvironmentId.mockRejectedValue(underlyingError);
|
||||
|
||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
surveyId,
|
||||
contactId: "clh123456789012345678901234",
|
||||
}),
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ environmentId }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Something went wrong. Please try again.",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
||||
request,
|
||||
status: 500,
|
||||
error: underlyingError,
|
||||
});
|
||||
expect(mocks.createDisplay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||
import {
|
||||
TDisplayCreateInputV2,
|
||||
ZDisplayCreateInputV2,
|
||||
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
||||
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createDisplay } from "./lib/display";
|
||||
@@ -13,6 +16,29 @@ interface Context {
|
||||
}>;
|
||||
}
|
||||
|
||||
type TValidatedDisplayInputResult = { displayInputData: TDisplayCreateInputV2 } | { response: Response };
|
||||
|
||||
const parseAndValidateDisplayInput = async (
|
||||
request: Request,
|
||||
environmentId: string
|
||||
): Promise<TValidatedDisplayInputResult> => {
|
||||
const inputValidation = await parseAndValidateJsonBody({
|
||||
request,
|
||||
schema: ZDisplayCreateInputV2,
|
||||
buildInput: (jsonInput) => ({
|
||||
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
|
||||
environmentId,
|
||||
}),
|
||||
malformedJsonMessage: "Invalid JSON in request body",
|
||||
});
|
||||
|
||||
if ("response" in inputValidation) {
|
||||
return inputValidation;
|
||||
}
|
||||
|
||||
return { displayInputData: inputValidation.data };
|
||||
};
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse(
|
||||
{},
|
||||
@@ -25,38 +51,40 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const jsonInput = await request.json();
|
||||
const inputValidation = ZDisplayCreateInputV2.safeParse({
|
||||
...jsonInput,
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
const validatedInput = await parseAndValidateDisplayInput(request, params.environmentId);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
if ("response" in validatedInput) {
|
||||
return validatedInput.response;
|
||||
}
|
||||
|
||||
if (inputValidation.data.contactId) {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
}
|
||||
}
|
||||
const { displayInputData } = validatedInput;
|
||||
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
if (displayInputData.contactId) {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse(
|
||||
"User identification is only available for enterprise users.",
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await createDisplay(displayInputData);
|
||||
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
|
||||
} else {
|
||||
logger.error({ error, url: request.url }, "Error creating display");
|
||||
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
|
||||
return responses.notFoundResponse("Survey", displayInputData.surveyId, true);
|
||||
}
|
||||
|
||||
const response = responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
|
||||
reportApiError({
|
||||
request,
|
||||
status: response.status,
|
||||
error,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { type NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
getEnvironmentState: vi.fn(),
|
||||
contextualLoggerError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/client/[environmentId]/environment/lib/environmentState", () => ({
|
||||
getEnvironmentState: mocks.getEnvironmentState,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: mocks.applyIPRateLimit,
|
||||
applyRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
api: {
|
||||
client: { windowMs: 60000, max: 100 },
|
||||
v1: { windowMs: 60000, max: 1000 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
error: mocks.contextualLoggerError,
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
})),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
IS_PRODUCTION: true,
|
||||
SENTRY_DSN: "test-dsn",
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
};
|
||||
});
|
||||
|
||||
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
return {
|
||||
method: "GET",
|
||||
url,
|
||||
headers: {
|
||||
get: (key: string) => headers.get(key),
|
||||
},
|
||||
nextUrl: {
|
||||
pathname: parsedUrl.pathname,
|
||||
},
|
||||
} as unknown as NextRequest;
|
||||
};
|
||||
|
||||
describe("api/v2 client environment route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.applyIPRateLimit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
|
||||
const underlyingError = new Error("Environment load failed");
|
||||
mocks.getEnvironmentState.mockRejectedValue(underlyingError);
|
||||
|
||||
const request = createMockRequest(
|
||||
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
|
||||
new Map([["x-request-id", "req-v2-env"]])
|
||||
);
|
||||
|
||||
const { GET } = await import("./route");
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({
|
||||
environmentId: "ck12345678901234567890123",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "An error occurred while processing your request.",
|
||||
details: {},
|
||||
});
|
||||
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
underlyingError,
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "req-v2-env",
|
||||
method: "GET",
|
||||
path: "/api/v2/client/ck12345678901234567890123/environment",
|
||||
}),
|
||||
extra: expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
name: "Error",
|
||||
message: "Environment load failed",
|
||||
}),
|
||||
originalError: expect.objectContaining({
|
||||
name: "Error",
|
||||
message: "Environment load failed",
|
||||
}),
|
||||
}),
|
||||
contexts: expect.objectContaining({
|
||||
apiRequest: expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "req-v2-env",
|
||||
method: "GET",
|
||||
path: "/api/v2/client/ck12345678901234567890123/environment",
|
||||
status: 500,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkSurveyValidity: vi.fn(),
|
||||
createResponseWithQuotaEvaluation: vi.fn(),
|
||||
getClientIpFromHeaders: vi.fn(),
|
||||
getIsContactsEnabled: vi.fn(),
|
||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
||||
getSurvey: vi.fn(),
|
||||
reportApiError: vi.fn(),
|
||||
sendToPipeline: vi.fn(),
|
||||
validateResponseData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/utils", () => ({
|
||||
checkSurveyValidity: mocks.checkSurveyValidity,
|
||||
}));
|
||||
|
||||
vi.mock("./lib/response", () => ({
|
||||
createResponseWithQuotaEvaluation: mocks.createResponseWithQuotaEvaluation,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/api-error-reporter", () => ({
|
||||
reportApiError: mocks.reportApiError,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/pipelines", () => ({
|
||||
sendToPipeline: mocks.sendToPipeline,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: mocks.getSurvey,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/client-ip", () => ({
|
||||
getClientIpFromHeaders: mocks.getClientIpFromHeaders,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/validation", () => ({
|
||||
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
|
||||
validateResponseData: mocks.validateResponseData,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsContactsEnabled: mocks.getIsContactsEnabled,
|
||||
}));
|
||||
|
||||
const environmentId = "cld1234567890abcdef123456";
|
||||
const surveyId = "clg123456789012345678901234";
|
||||
|
||||
describe("api/v2 client responses route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkSurveyValidity.mockResolvedValue(null);
|
||||
mocks.getSurvey.mockResolvedValue({
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
blocks: [],
|
||||
questions: [],
|
||||
isCaptureIpEnabled: false,
|
||||
});
|
||||
mocks.validateResponseData.mockReturnValue(null);
|
||||
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
|
||||
mocks.getIsContactsEnabled.mockResolvedValue(true);
|
||||
mocks.getClientIpFromHeaders.mockResolvedValue("127.0.0.1");
|
||||
});
|
||||
|
||||
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
|
||||
const underlyingError = new Error("response persistence failed");
|
||||
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
|
||||
|
||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-request-id": "req-v2-response",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
surveyId,
|
||||
finished: false,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ environmentId }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Something went wrong. Please try again.",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
||||
request,
|
||||
status: 500,
|
||||
error: underlyingError,
|
||||
});
|
||||
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports unexpected pre-persistence failures with the same generic public response", async () => {
|
||||
const underlyingError = new Error("survey lookup failed");
|
||||
mocks.getSurvey.mockRejectedValue(underlyingError);
|
||||
|
||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-request-id": "req-v2-response-pre-check",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
surveyId,
|
||||
finished: false,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ environmentId }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Something went wrong. Please try again.",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
||||
request,
|
||||
status: 500,
|
||||
error: underlyingError,
|
||||
});
|
||||
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
||||
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
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";
|
||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
||||
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
@@ -25,78 +25,86 @@ interface Context {
|
||||
}>;
|
||||
}
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse(
|
||||
{},
|
||||
true,
|
||||
// Cache CORS preflight responses for 1 hour (conservative approach)
|
||||
// Balances performance gains with flexibility for CORS policy changes
|
||||
"public, s-maxage=3600, max-age=3600"
|
||||
);
|
||||
};
|
||||
type TResponseSurvey = NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
try {
|
||||
responseInput = await request.json();
|
||||
} catch (error) {
|
||||
return responses.badRequestResponse(
|
||||
"Invalid JSON in request body",
|
||||
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
|
||||
true
|
||||
);
|
||||
}
|
||||
type TValidatedResponseInputResult =
|
||||
| {
|
||||
environmentId: string;
|
||||
responseInputData: TResponseInputV2;
|
||||
}
|
||||
| { response: Response };
|
||||
|
||||
const { environmentId } = params;
|
||||
const getCountry = (requestHeaders: Headers): string | undefined =>
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
|
||||
const getUnexpectedPublicErrorResponse = (): Response =>
|
||||
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
|
||||
|
||||
const parseAndValidateResponseInput = async (
|
||||
request: Request,
|
||||
environmentId: string
|
||||
): Promise<TValidatedResponseInputResult> => {
|
||||
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
|
||||
);
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!responseInputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(responseInputValidation.error),
|
||||
true
|
||||
);
|
||||
const responseInputValidation = await parseAndValidateJsonBody({
|
||||
request,
|
||||
schema: ZResponseInputV2,
|
||||
buildInput: (jsonInput) => ({
|
||||
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
|
||||
environmentId,
|
||||
}),
|
||||
malformedJsonMessage: "Invalid JSON in request body",
|
||||
});
|
||||
|
||||
if ("response" in responseInputValidation) {
|
||||
return responseInputValidation;
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const agent = new UAParser(userAgent);
|
||||
return {
|
||||
environmentId,
|
||||
responseInputData: responseInputValidation.data,
|
||||
};
|
||||
};
|
||||
|
||||
const country =
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
|
||||
const responseInputData = responseInputValidation.data;
|
||||
|
||||
if (responseInputData.contactId) {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
}
|
||||
const getContactsDisabledResponse = async (
|
||||
environmentId: string,
|
||||
contactId: string | null | undefined
|
||||
): Promise<Response | null> => {
|
||||
if (!contactId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInputData.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
|
||||
}
|
||||
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInput);
|
||||
if (surveyCheckResult) return surveyCheckResult;
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
|
||||
return isContactsEnabled
|
||||
? null
|
||||
: responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
};
|
||||
|
||||
const validateResponseSubmission = async (
|
||||
environmentId: string,
|
||||
responseInputData: TResponseInputV2,
|
||||
survey: TResponseSurvey
|
||||
): Promise<Response | null> => {
|
||||
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInputData);
|
||||
if (surveyCheckResult) {
|
||||
return surveyCheckResult;
|
||||
}
|
||||
|
||||
// Validate response data for "other" options exceeding character limit
|
||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||
responseData: responseInputData.data,
|
||||
surveyQuestions: getElementsFromBlocks(survey.blocks),
|
||||
@@ -113,7 +121,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
);
|
||||
}
|
||||
|
||||
// Validate response data against validation rules
|
||||
const validationErrors = validateResponseData(
|
||||
survey.blocks,
|
||||
responseInputData.data,
|
||||
@@ -121,15 +128,29 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
survey.questions
|
||||
);
|
||||
|
||||
if (validationErrors) {
|
||||
return responses.badRequestResponse(
|
||||
"Validation failed",
|
||||
formatValidationErrorsForV1Api(validationErrors),
|
||||
true
|
||||
);
|
||||
}
|
||||
return validationErrors
|
||||
? responses.badRequestResponse(
|
||||
"Validation failed",
|
||||
formatValidationErrorsForV1Api(validationErrors),
|
||||
true
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const createResponseForRequest = async ({
|
||||
request,
|
||||
survey,
|
||||
responseInputData,
|
||||
country,
|
||||
}: {
|
||||
request: Request;
|
||||
survey: TResponseSurvey;
|
||||
responseInputData: TResponseInputV2;
|
||||
country: string | undefined;
|
||||
}): Promise<TResponseWithQuotaFull | Response> => {
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const agent = new UAParser(userAgent);
|
||||
|
||||
let response: TResponseWithQuotaFull;
|
||||
try {
|
||||
const meta: TResponseInputV2["meta"] = {
|
||||
source: responseInputData?.meta?.source,
|
||||
@@ -139,54 +160,115 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
device: agent.getDevice().type || "desktop",
|
||||
os: agent.getOS().name,
|
||||
},
|
||||
country: country,
|
||||
country,
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
// Capture IP address if the survey has IP capture enabled
|
||||
// Server-derived IP always overwrites any client-provided value
|
||||
if (survey.isCaptureIpEnabled) {
|
||||
const ipAddress = await getClientIpFromHeaders();
|
||||
meta.ipAddress = ipAddress;
|
||||
meta.ipAddress = await getClientIpFromHeaders();
|
||||
}
|
||||
|
||||
response = await createResponseWithQuotaEvaluation({
|
||||
return await createResponseWithQuotaEvaluation({
|
||||
...responseInputData,
|
||||
meta,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
return responses.badRequestResponse(error.message, undefined, true);
|
||||
}
|
||||
logger.error({ error, url: request.url }, "Error creating response");
|
||||
return responses.internalServerErrorResponse(
|
||||
error instanceof Error ? error.message : "Unknown error occurred"
|
||||
);
|
||||
|
||||
const response = getUnexpectedPublicErrorResponse();
|
||||
reportApiError({
|
||||
request,
|
||||
status: response.status,
|
||||
error,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
const { quotaFull, ...responseData } = response;
|
||||
};
|
||||
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
environmentId,
|
||||
surveyId: responseData.surveyId,
|
||||
response: responseData,
|
||||
});
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse(
|
||||
{},
|
||||
true,
|
||||
// Cache CORS preflight responses for 1 hour (conservative approach)
|
||||
// Balances performance gains with flexibility for CORS policy changes
|
||||
"public, s-maxage=3600, max-age=3600"
|
||||
);
|
||||
};
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const validatedInput = await parseAndValidateResponseInput(request, params.environmentId);
|
||||
|
||||
if ("response" in validatedInput) {
|
||||
return validatedInput.response;
|
||||
}
|
||||
|
||||
const { environmentId, responseInputData } = validatedInput;
|
||||
const country = getCountry(request.headers);
|
||||
|
||||
try {
|
||||
const contactsDisabledResponse = await getContactsDisabledResponse(
|
||||
environmentId,
|
||||
responseInputData.contactId
|
||||
);
|
||||
if (contactsDisabledResponse) {
|
||||
return contactsDisabledResponse;
|
||||
}
|
||||
|
||||
const survey = await getSurvey(responseInputData.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
|
||||
}
|
||||
|
||||
const validationResponse = await validateResponseSubmission(environmentId, responseInputData, survey);
|
||||
if (validationResponse) {
|
||||
return validationResponse;
|
||||
}
|
||||
|
||||
const createdResponse = await createResponseForRequest({
|
||||
request,
|
||||
survey,
|
||||
responseInputData,
|
||||
country,
|
||||
});
|
||||
if (createdResponse instanceof Response) {
|
||||
return createdResponse;
|
||||
}
|
||||
const { quotaFull, ...responseData } = createdResponse;
|
||||
|
||||
if (responseData.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
event: "responseCreated",
|
||||
environmentId,
|
||||
surveyId: responseData.surveyId,
|
||||
response: responseData,
|
||||
});
|
||||
|
||||
if (responseData.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId,
|
||||
surveyId: responseData.surveyId,
|
||||
response: responseData,
|
||||
});
|
||||
}
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
id: responseData.id,
|
||||
...quotaObj,
|
||||
};
|
||||
|
||||
return responses.successResponse(responseDataWithQuota, true);
|
||||
} catch (error) {
|
||||
const response = getUnexpectedPublicErrorResponse();
|
||||
reportApiError({
|
||||
request,
|
||||
status: response.status,
|
||||
error,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
id: responseData.id,
|
||||
...quotaObj,
|
||||
};
|
||||
|
||||
return responses.successResponse(responseDataWithQuota, true);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { reportApiError } from "./api-error-reporter";
|
||||
|
||||
const loggerMocks = vi.hoisted(() => {
|
||||
const contextualError = vi.fn();
|
||||
const rootError = vi.fn();
|
||||
const withContext = vi.fn(() => ({
|
||||
error: contextualError,
|
||||
}));
|
||||
|
||||
return {
|
||||
contextualError,
|
||||
rootError,
|
||||
withContext,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: loggerMocks.withContext,
|
||||
error: loggerMocks.rootError,
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
IS_PRODUCTION: true,
|
||||
SENTRY_DSN: "dsn",
|
||||
};
|
||||
});
|
||||
|
||||
describe("reportApiError", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("captures real errors directly with structured context", () => {
|
||||
const request = new Request("https://app.test/api/v2/client/environment", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-request-id": "req-1",
|
||||
},
|
||||
});
|
||||
const error = new Error("boom");
|
||||
|
||||
reportApiError({
|
||||
request,
|
||||
status: 500,
|
||||
error,
|
||||
});
|
||||
|
||||
expect(loggerMocks.withContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "req-1",
|
||||
method: "POST",
|
||||
path: "/api/v2/client/environment",
|
||||
status: 500,
|
||||
error: expect.objectContaining({
|
||||
name: "Error",
|
||||
message: "boom",
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
error,
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "req-1",
|
||||
method: "POST",
|
||||
path: "/api/v2/client/environment",
|
||||
}),
|
||||
extra: expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
name: "Error",
|
||||
message: "boom",
|
||||
}),
|
||||
originalError: expect.objectContaining({
|
||||
name: "Error",
|
||||
message: "boom",
|
||||
}),
|
||||
}),
|
||||
contexts: expect.objectContaining({
|
||||
apiRequest: expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "req-1",
|
||||
method: "POST",
|
||||
path: "/api/v2/client/environment",
|
||||
status: 500,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("captures non-error payloads with a synthetic error while preserving additional data", () => {
|
||||
const request = new Request("https://app.test/api/v1/management/surveys", {
|
||||
headers: {
|
||||
"x-request-id": "req-2",
|
||||
},
|
||||
});
|
||||
const payload = {
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "server", issue: "error occurred" }],
|
||||
};
|
||||
|
||||
reportApiError({
|
||||
request,
|
||||
status: 500,
|
||||
error: payload,
|
||||
originalError: payload,
|
||||
});
|
||||
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "API V1 error, id: req-2",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
apiVersion: "v1",
|
||||
correlationId: "req-2",
|
||||
method: "GET",
|
||||
path: "/api/v1/management/surveys",
|
||||
}),
|
||||
extra: expect.objectContaining({
|
||||
error: payload,
|
||||
originalError: payload,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("swallows Sentry failures after logging a fallback reporter error", () => {
|
||||
vi.mocked(Sentry.captureException).mockImplementation(() => {
|
||||
throw new Error("sentry down");
|
||||
});
|
||||
|
||||
const request = new Request("https://app.test/api/v2/client/displays", {
|
||||
headers: {
|
||||
"x-request-id": "req-3",
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
reportApiError({
|
||||
request,
|
||||
status: 500,
|
||||
error: new Error("boom"),
|
||||
})
|
||||
).not.toThrow();
|
||||
|
||||
expect(loggerMocks.rootError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "req-3",
|
||||
method: "GET",
|
||||
path: "/api/v2/client/displays",
|
||||
status: 500,
|
||||
reportingError: expect.objectContaining({
|
||||
name: "Error",
|
||||
message: "sentry down",
|
||||
}),
|
||||
}),
|
||||
"Failed to report API error"
|
||||
);
|
||||
});
|
||||
|
||||
test("serializes cyclic payloads without throwing", () => {
|
||||
const request = new Request("https://app.test/api/v2/client/responses", {
|
||||
headers: {
|
||||
"x-request-id": "req-4",
|
||||
},
|
||||
});
|
||||
const payload: Record<string, unknown> = {
|
||||
type: "internal_server_error",
|
||||
};
|
||||
|
||||
payload.self = payload;
|
||||
|
||||
expect(() =>
|
||||
reportApiError({
|
||||
request,
|
||||
status: 500,
|
||||
error: payload,
|
||||
originalError: payload,
|
||||
})
|
||||
).not.toThrow();
|
||||
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "API V2 error, id: req-4",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
extra: expect.objectContaining({
|
||||
error: {
|
||||
type: "internal_server_error",
|
||||
self: "[Circular]",
|
||||
},
|
||||
originalError: {
|
||||
type: "internal_server_error",
|
||||
self: "[Circular]",
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
|
||||
type TRequestLike = Pick<Request, "method" | "url" | "headers">;
|
||||
|
||||
type TApiErrorContext = {
|
||||
apiVersion: TApiVersion;
|
||||
correlationId: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
type TSentryCaptureContext = NonNullable<Parameters<typeof Sentry.captureException>[1]>;
|
||||
|
||||
export type TApiVersion = "v1" | "v2" | "v3" | "unknown";
|
||||
|
||||
const getPathname = (url: string): string => {
|
||||
if (url.startsWith("/")) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export const getApiVersionFromPath = (pathname: string): TApiVersion => {
|
||||
const match = /^\/api\/(v\d+)(?:\/|$)/.exec(pathname);
|
||||
|
||||
if (!match) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
switch (match[1]) {
|
||||
case "v1":
|
||||
case "v2":
|
||||
case "v3":
|
||||
return match[1];
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
const serializeError = (value: unknown, seen = new WeakSet<object>()): unknown => {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
|
||||
if (value instanceof Error) {
|
||||
const serializedError: Record<string, unknown> = {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
};
|
||||
|
||||
if (value.stack) {
|
||||
serializedError.stack = value.stack;
|
||||
}
|
||||
|
||||
if ("cause" in value && value.cause !== undefined) {
|
||||
serializedError.cause = serializeError(value.cause, seen);
|
||||
}
|
||||
|
||||
for (const [key, entryValue] of Object.entries(value as unknown as Record<string, unknown>)) {
|
||||
serializedError[key] = serializeError(entryValue, seen);
|
||||
}
|
||||
|
||||
return serializedError;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => serializeError(item, seen));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [
|
||||
key,
|
||||
serializeError(entryValue, seen),
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const getSerializedValueType = (value: unknown): string => {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return "array";
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return value.name;
|
||||
}
|
||||
|
||||
return typeof value;
|
||||
};
|
||||
|
||||
export const serializeErrorSafely = (value: unknown): unknown => {
|
||||
try {
|
||||
return serializeError(value);
|
||||
} catch (serializationError) {
|
||||
return {
|
||||
name: "ErrorSerializationFailed",
|
||||
message: "Failed to serialize API error payload",
|
||||
originalType: getSerializedValueType(value),
|
||||
serializationError:
|
||||
serializationError instanceof Error ? serializationError.message : String(serializationError),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getSyntheticError = (apiVersion: TApiVersion, correlationId: string): Error => {
|
||||
if (apiVersion === "unknown") {
|
||||
return new Error(`API error, id: ${correlationId}`);
|
||||
}
|
||||
|
||||
return new Error(`API ${apiVersion.toUpperCase()} error, id: ${correlationId}`);
|
||||
};
|
||||
|
||||
const getLogMessage = (apiVersion: TApiVersion): string => {
|
||||
switch (apiVersion) {
|
||||
case "v1":
|
||||
return "API V1 Error Details";
|
||||
case "v2":
|
||||
return "API V2 Error Details";
|
||||
case "v3":
|
||||
return "API V3 Error Details";
|
||||
default:
|
||||
return "API Error Details";
|
||||
}
|
||||
};
|
||||
|
||||
const buildApiErrorContext = ({
|
||||
request,
|
||||
status,
|
||||
apiVersion,
|
||||
}: {
|
||||
request: TRequestLike;
|
||||
status: number;
|
||||
apiVersion?: TApiVersion;
|
||||
}): TApiErrorContext => {
|
||||
const path = getPathname(request.url);
|
||||
|
||||
return {
|
||||
apiVersion: apiVersion ?? getApiVersionFromPath(path),
|
||||
correlationId: request.headers.get("x-request-id") ?? "",
|
||||
method: request.method,
|
||||
path,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSentryCaptureContext = ({
|
||||
context,
|
||||
errorPayload,
|
||||
originalErrorPayload,
|
||||
}: {
|
||||
context: TApiErrorContext;
|
||||
errorPayload: unknown;
|
||||
originalErrorPayload: unknown;
|
||||
}): TSentryCaptureContext => ({
|
||||
level: "error",
|
||||
tags: {
|
||||
apiVersion: context.apiVersion,
|
||||
correlationId: context.correlationId,
|
||||
method: context.method,
|
||||
path: context.path,
|
||||
},
|
||||
extra: {
|
||||
error: errorPayload,
|
||||
originalError: originalErrorPayload,
|
||||
},
|
||||
contexts: {
|
||||
apiRequest: {
|
||||
apiVersion: context.apiVersion,
|
||||
correlationId: context.correlationId,
|
||||
method: context.method,
|
||||
path: context.path,
|
||||
status: context.status,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const emitApiErrorLog = (context: TApiErrorContext, errorPayload?: unknown): void => {
|
||||
const logContext =
|
||||
errorPayload === undefined
|
||||
? context
|
||||
: {
|
||||
...context,
|
||||
error: errorPayload,
|
||||
};
|
||||
|
||||
logger.withContext(logContext).error(getLogMessage(context.apiVersion));
|
||||
};
|
||||
|
||||
export const emitApiErrorToSentry = ({
|
||||
error,
|
||||
captureContext,
|
||||
}: {
|
||||
error: Error;
|
||||
captureContext: TSentryCaptureContext;
|
||||
}): void => {
|
||||
Sentry.captureException(error, captureContext);
|
||||
};
|
||||
|
||||
const logReporterFailure = (context: TApiErrorContext, reportingError: unknown): void => {
|
||||
try {
|
||||
logger.error(
|
||||
{
|
||||
apiVersion: context.apiVersion,
|
||||
correlationId: context.correlationId,
|
||||
method: context.method,
|
||||
path: context.path,
|
||||
status: context.status,
|
||||
reportingError: serializeErrorSafely(reportingError),
|
||||
},
|
||||
"Failed to report API error"
|
||||
);
|
||||
} catch {
|
||||
// Swallow reporter failures so API responses are never affected by observability issues.
|
||||
}
|
||||
};
|
||||
|
||||
export const reportApiError = ({
|
||||
request,
|
||||
status,
|
||||
error,
|
||||
apiVersion,
|
||||
originalError,
|
||||
}: {
|
||||
request: TRequestLike;
|
||||
status: number;
|
||||
error?: unknown;
|
||||
apiVersion?: TApiVersion;
|
||||
originalError?: unknown;
|
||||
}): void => {
|
||||
const context = buildApiErrorContext({
|
||||
request,
|
||||
status,
|
||||
apiVersion,
|
||||
});
|
||||
const capturedError =
|
||||
error instanceof Error ? error : getSyntheticError(context.apiVersion, context.correlationId);
|
||||
const logErrorPayload = error === undefined ? undefined : serializeErrorSafely(error);
|
||||
const errorPayload = serializeErrorSafely(error ?? capturedError);
|
||||
const originalErrorPayload = serializeErrorSafely(originalError ?? error);
|
||||
|
||||
try {
|
||||
emitApiErrorLog(context, logErrorPayload);
|
||||
} catch (reportingError) {
|
||||
logReporterFailure(context, reportingError);
|
||||
}
|
||||
|
||||
if (SENTRY_DSN && IS_PRODUCTION && status >= 500) {
|
||||
try {
|
||||
emitApiErrorToSentry({
|
||||
error: capturedError,
|
||||
captureContext: buildSentryCaptureContext({
|
||||
context,
|
||||
errorPayload,
|
||||
originalErrorPayload,
|
||||
}),
|
||||
});
|
||||
} catch (reportingError) {
|
||||
logReporterFailure(context, reportingError);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { parseAndValidateJsonBody } from "./parse-and-validate-json-body";
|
||||
|
||||
describe("parseAndValidateJsonBody", () => {
|
||||
test("returns a malformed JSON response when request parsing fails", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{invalid-json",
|
||||
});
|
||||
|
||||
const result = await parseAndValidateJsonBody({
|
||||
request,
|
||||
schema: z.object({
|
||||
finished: z.boolean(),
|
||||
}),
|
||||
malformedJsonMessage: "Malformed JSON in request body",
|
||||
});
|
||||
|
||||
expect("response" in result).toBe(true);
|
||||
|
||||
if (!("response" in result)) {
|
||||
throw new Error("Expected a response result");
|
||||
}
|
||||
|
||||
expect(result.issue).toBe("invalid_json");
|
||||
expect(result.details).toEqual({
|
||||
error: expect.any(String),
|
||||
});
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "bad_request",
|
||||
message: "Malformed JSON in request body",
|
||||
details: {
|
||||
error: expect.any(String),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a validation response when the parsed JSON does not match the schema", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
finished: "not-boolean",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseAndValidateJsonBody({
|
||||
request,
|
||||
schema: z.object({
|
||||
finished: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
expect("response" in result).toBe(true);
|
||||
|
||||
if (!("response" in result)) {
|
||||
throw new Error("Expected a response result");
|
||||
}
|
||||
|
||||
expect(result.issue).toBe("invalid_body");
|
||||
expect(result.details).toEqual(
|
||||
expect.objectContaining({
|
||||
finished: expect.any(String),
|
||||
})
|
||||
);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "bad_request",
|
||||
message: "Fields are missing or incorrectly formatted",
|
||||
details: expect.objectContaining({
|
||||
finished: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("returns parsed data when JSON parsing and schema validation succeed", async () => {
|
||||
const request = new Request("http://localhost/api/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
finished: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseAndValidateJsonBody({
|
||||
request,
|
||||
schema: z.object({
|
||||
finished: z.boolean(),
|
||||
environmentId: z.string(),
|
||||
}),
|
||||
buildInput: (jsonInput) => ({
|
||||
...(jsonInput as Record<string, unknown>),
|
||||
environmentId: "env_123",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
environmentId: "env_123",
|
||||
finished: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { z } from "zod";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
|
||||
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body";
|
||||
|
||||
type TJsonBodyValidationError = {
|
||||
details: Record<string, string> | { error: string };
|
||||
issue: TJsonBodyValidationIssue;
|
||||
response: Response;
|
||||
};
|
||||
|
||||
type TJsonBodyValidationSuccess<TData> = {
|
||||
data: TData;
|
||||
};
|
||||
|
||||
export type TParseAndValidateJsonBodyResult<TData> =
|
||||
| TJsonBodyValidationError
|
||||
| TJsonBodyValidationSuccess<TData>;
|
||||
|
||||
type TParseAndValidateJsonBodyOptions<TSchema extends z.ZodTypeAny> = {
|
||||
request: Request;
|
||||
schema: TSchema;
|
||||
buildInput?: (jsonInput: unknown) => unknown;
|
||||
malformedJsonMessage?: string;
|
||||
validationMessage?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MALFORMED_JSON_MESSAGE = "Malformed JSON input, please check your request body";
|
||||
const DEFAULT_VALIDATION_MESSAGE = "Fields are missing or incorrectly formatted";
|
||||
|
||||
const getErrorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
|
||||
export const parseAndValidateJsonBody = async <TSchema extends z.ZodTypeAny>({
|
||||
request,
|
||||
schema,
|
||||
buildInput,
|
||||
malformedJsonMessage = DEFAULT_MALFORMED_JSON_MESSAGE,
|
||||
validationMessage = DEFAULT_VALIDATION_MESSAGE,
|
||||
}: TParseAndValidateJsonBodyOptions<TSchema>): Promise<
|
||||
TParseAndValidateJsonBodyResult<z.output<TSchema>>
|
||||
> => {
|
||||
let jsonInput: unknown;
|
||||
|
||||
try {
|
||||
jsonInput = await request.json();
|
||||
} catch (error) {
|
||||
const details = { error: getErrorMessage(error) };
|
||||
|
||||
return {
|
||||
details,
|
||||
issue: "invalid_json",
|
||||
response: responses.badRequestResponse(malformedJsonMessage, details, true),
|
||||
};
|
||||
}
|
||||
|
||||
const inputValidation = schema.safeParse(buildInput ? buildInput(jsonInput) : jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
const details = transformErrorToDetails(inputValidation.error);
|
||||
|
||||
return {
|
||||
details,
|
||||
issue: "invalid_body",
|
||||
response: responses.badRequestResponse(validationMessage, details, true),
|
||||
};
|
||||
}
|
||||
|
||||
return { data: inputValidation.data };
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import { responses } from "./response";
|
||||
|
||||
// Mocks
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
__esModule: true,
|
||||
queueAuditEvent: vi.fn(),
|
||||
@@ -14,24 +13,13 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn((callback) => {
|
||||
callback(mockSentryScope);
|
||||
return mockSentryScope;
|
||||
}),
|
||||
withScope: vi.fn(),
|
||||
}));
|
||||
|
||||
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
|
||||
const mockContextualLoggerError = vi.fn();
|
||||
const mockContextualLoggerWarn = vi.fn();
|
||||
const mockContextualLoggerInfo = vi.fn();
|
||||
|
||||
// Mock Sentry scope that can be referenced in tests
|
||||
const mockSentryScope = {
|
||||
setTag: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
};
|
||||
const V1_MANAGEMENT_SURVEYS_URL = "https://api.test/api/v1/management/surveys";
|
||||
|
||||
vi.mock("@formbricks/logger", () => {
|
||||
const mockWithContextInstance = vi.fn(() => ({
|
||||
@@ -86,7 +74,6 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
}));
|
||||
|
||||
function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) {
|
||||
// Parse the URL to get the pathname
|
||||
const parsedUrl = url.startsWith("/") ? new URL(url, "http://localhost:3000") : new URL(url);
|
||||
|
||||
return {
|
||||
@@ -122,12 +109,6 @@ describe("withV1ApiWrapper", () => {
|
||||
}));
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock Sentry scope calls
|
||||
mockSentryScope.setTag.mockClear();
|
||||
mockSentryScope.setExtra.mockClear();
|
||||
mockSentryScope.setContext.mockClear();
|
||||
mockSentryScope.setLevel.mockClear();
|
||||
});
|
||||
|
||||
test("logs and audits on error response with API key authentication", async () => {
|
||||
@@ -155,7 +136,7 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://api.test/v1/management/surveys",
|
||||
url: V1_MANAGEMENT_SURVEYS_URL,
|
||||
headers: new Map([["x-request-id", "abc-123"]]),
|
||||
});
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
@@ -177,9 +158,33 @@ describe("withV1ApiWrapper", () => {
|
||||
organizationId: "org-1",
|
||||
})
|
||||
);
|
||||
expect(Sentry.withScope).toHaveBeenCalled();
|
||||
expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
apiVersion: "v1",
|
||||
correlationId: "abc-123",
|
||||
method: "GET",
|
||||
path: "/api/v1/management/surveys",
|
||||
}),
|
||||
extra: expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "API V1 error, id: abc-123",
|
||||
}),
|
||||
originalError: undefined,
|
||||
}),
|
||||
contexts: expect.objectContaining({
|
||||
apiRequest: expect.objectContaining({
|
||||
apiVersion: "v1",
|
||||
correlationId: "abc-123",
|
||||
method: "GET",
|
||||
path: "/api/v1/management/surveys",
|
||||
status: 500,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("does not log Sentry if not 500", async () => {
|
||||
@@ -206,7 +211,7 @@ describe("withV1ApiWrapper", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
|
||||
await wrapped(req, undefined);
|
||||
@@ -251,7 +256,7 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://api.test/v1/management/surveys",
|
||||
url: V1_MANAGEMENT_SURVEYS_URL,
|
||||
headers: new Map([["x-request-id", "err-1"]]),
|
||||
});
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
@@ -280,8 +285,78 @@ describe("withV1ApiWrapper", () => {
|
||||
organizationId: "org-1",
|
||||
})
|
||||
);
|
||||
expect(Sentry.withScope).toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "fail!",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
apiVersion: "v1",
|
||||
correlationId: "err-1",
|
||||
method: "GET",
|
||||
path: "/api/v1/management/surveys",
|
||||
}),
|
||||
extra: expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "fail!",
|
||||
}),
|
||||
originalError: expect.objectContaining({
|
||||
message: "fail!",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("uses handler result error for handled 500 responses", async () => {
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handledError = new Error("handled failure");
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.internalServerErrorResponse("fail"),
|
||||
error: handledError,
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
url: "https://api.test/api/v2/client/environment",
|
||||
headers: new Map([["x-request-id", "handled-1"]]),
|
||||
});
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
handledError,
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "handled-1",
|
||||
method: "GET",
|
||||
path: "/api/v2/client/environment",
|
||||
}),
|
||||
extra: expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "handled failure",
|
||||
}),
|
||||
originalError: expect.objectContaining({
|
||||
message: "handled failure",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("does not log on success response but still audits", async () => {
|
||||
@@ -308,7 +383,7 @@ describe("withV1ApiWrapper", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
|
||||
await wrapped(req, undefined);
|
||||
@@ -358,7 +433,7 @@ describe("withV1ApiWrapper", () => {
|
||||
response: responses.internalServerErrorResponse("fail"),
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
|
||||
await wrapped(req, undefined);
|
||||
|
||||
@@ -378,7 +453,7 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
@@ -412,7 +487,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
@@ -471,7 +546,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
@@ -499,7 +574,7 @@ describe("withV1ApiWrapper", () => {
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
|
||||
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
await wrapped(req, undefined);
|
||||
@@ -518,7 +593,7 @@ describe("buildAuditLogBaseObject", () => {
|
||||
test("creates audit log base object with correct structure", async () => {
|
||||
const { buildAuditLogBaseObject } = await import("./with-api-logging");
|
||||
|
||||
const result = buildAuditLogBaseObject("created", "survey", "https://api.test/v1/management/surveys");
|
||||
const result = buildAuditLogBaseObject("created", "survey", V1_MANAGEMENT_SURVEYS_URL);
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "created",
|
||||
@@ -530,7 +605,7 @@ describe("buildAuditLogBaseObject", () => {
|
||||
oldObject: undefined,
|
||||
newObject: undefined,
|
||||
userType: "api",
|
||||
apiUrl: "https://api.test/v1/management/surveys",
|
||||
apiUrl: V1_MANAGEMENT_SURVEYS_URL,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Session, getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
AuthenticationMethod,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isIntegrationRoute,
|
||||
isManagementApiRoute,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
@@ -33,7 +33,10 @@ export interface THandlerParams<TProps = unknown> {
|
||||
}
|
||||
|
||||
// Interface for wrapper function parameters
|
||||
export interface TWithV1ApiWrapperParams<TResult extends { response: Response }, TProps = unknown> {
|
||||
export interface TWithV1ApiWrapperParams<
|
||||
TResult extends { response: Response; error?: unknown },
|
||||
TProps = unknown,
|
||||
> {
|
||||
handler: (params: THandlerParams<TProps>) => Promise<TResult>;
|
||||
action?: TAuditAction;
|
||||
targetType?: TAuditTarget;
|
||||
@@ -93,7 +96,7 @@ const handleRateLimiting = async (
|
||||
/**
|
||||
* Execute handler with error handling
|
||||
*/
|
||||
const executeHandler = async <TResult extends { response: Response }, TProps>(
|
||||
const executeHandler = async <TResult extends { response: Response; error?: unknown }, TProps>(
|
||||
handler: (params: THandlerParams<TProps>) => Promise<TResult>,
|
||||
req: NextRequest,
|
||||
props: TProps,
|
||||
@@ -158,34 +161,12 @@ const handleAuthentication = async (
|
||||
/**
|
||||
* Log error details to system logger and Sentry
|
||||
*/
|
||||
const logErrorDetails = (res: Response, req: NextRequest, correlationId: string, error?: any): void => {
|
||||
const logContext = {
|
||||
correlationId,
|
||||
method: req.method,
|
||||
path: req.url,
|
||||
const logErrorDetails = (res: Response, req: NextRequest, error?: unknown): void => {
|
||||
reportApiError({
|
||||
request: req,
|
||||
status: res.status,
|
||||
...(error && { error }),
|
||||
};
|
||||
|
||||
logger.withContext(logContext).error("V1 API Error Details");
|
||||
|
||||
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
|
||||
// Set correlation ID as a tag for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setLevel("error");
|
||||
|
||||
// If we have an actual error, capture it with full stacktrace
|
||||
// Otherwise, create a generic error with context
|
||||
if (error instanceof Error) {
|
||||
Sentry.captureException(error);
|
||||
} else {
|
||||
scope.setExtra("originalError", error);
|
||||
const genericError = new Error(`API V1 error, id: ${correlationId}`);
|
||||
Sentry.captureException(genericError);
|
||||
}
|
||||
});
|
||||
}
|
||||
error,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -195,7 +176,7 @@ const processResponse = async (
|
||||
res: Response,
|
||||
req: NextRequest,
|
||||
auditLog?: TApiAuditLog,
|
||||
error?: any
|
||||
error?: unknown
|
||||
): Promise<void> => {
|
||||
const correlationId = req.headers.get("x-request-id") ?? "";
|
||||
|
||||
@@ -210,7 +191,7 @@ const processResponse = async (
|
||||
|
||||
// Handle error logging
|
||||
if (!res.ok) {
|
||||
logErrorDetails(res, req, correlationId, error);
|
||||
logErrorDetails(res, req, error);
|
||||
}
|
||||
|
||||
// Queue audit event if enabled and audit log exists
|
||||
@@ -267,7 +248,7 @@ const getRouteType = (
|
||||
* @returns Wrapped handler function that returns the final HTTP response
|
||||
*
|
||||
*/
|
||||
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
|
||||
export const withV1ApiWrapper = <TResult extends { response: Response; error?: unknown }, TProps = unknown>(
|
||||
params: TWithV1ApiWrapperParams<TResult, TProps>
|
||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
||||
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
|
||||
@@ -312,9 +293,10 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
|
||||
// === Handler Execution ===
|
||||
const { result, error } = await executeHandler(handler, req, props, auditLog, authentication);
|
||||
const res = result.response;
|
||||
const reportedError = result.error ?? error;
|
||||
|
||||
// === Response Processing & Logging ===
|
||||
await processResponse(res, req, auditLog, error);
|
||||
await processResponse(res, req, auditLog, reportedError);
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
+23
-24
@@ -140,6 +140,7 @@ checksums:
|
||||
common/connect: 8778ee245078a8be4a2ce855c8c56edc
|
||||
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
|
||||
common/connected: aa0ceca574641de34c74b9e590664230
|
||||
common/contact: 9afa39bc47019ee6dec6c74b6273967c
|
||||
common/contacts: d5b6c3f890b3904eaf5754081945c03d
|
||||
common/continue: 3cfba90b4600131e82fc4260c568d044
|
||||
common/copied: 29208e06d704c4fc4b8b534dc7acc4ef
|
||||
@@ -187,12 +188,12 @@ checksums:
|
||||
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
|
||||
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/elements: 8cb054d952b341e5965284860d532bc7
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
|
||||
common/environment: 0844e8dc1485339c8de066dc0a9bb6a1
|
||||
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
|
||||
common/environment_notice: 228a8668be1812e031f438d166861729
|
||||
common/error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
|
||||
@@ -256,7 +257,9 @@ checksums:
|
||||
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
|
||||
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
|
||||
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
|
||||
common/meta: 842eac888f134f3525f8ea613d933687
|
||||
common/metadata: 695d4f7da261ba76e3be4de495491028
|
||||
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
|
||||
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
|
||||
@@ -296,10 +299,9 @@ checksums:
|
||||
common/or: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||
common/organization: 3dc8489af7e74121f65ce6d9677bc94d
|
||||
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
|
||||
common/organization_not_found: 4cb8c07ec2c599b6f48750e06ffa182b
|
||||
common/organization_settings: 11528aa89ae9935e55dcb54478058775
|
||||
common/organization_teams_not_found: ce29fcb7a4e8b4582f92b65dea9b7d4e
|
||||
common/other: 79acaa6cd481262bea4e743a422529d2
|
||||
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
|
||||
common/others: 39160224ce0e35eb4eb252c997edf4d8
|
||||
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
|
||||
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
|
||||
@@ -371,7 +373,6 @@ checksums:
|
||||
common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79
|
||||
common/shown: 63e4ffb245c05e04b636446c3dbdd8df
|
||||
common/size: 227fadeeff951e041ff42031a11a4626
|
||||
common/skip: b7f28dfa2f58b80b149bb82b392d0291
|
||||
common/skipped: d496f0f667e1b4364b954db71335d4ef
|
||||
common/skips: 99de7579122a3fa6ec5e2a47f3fd8b34
|
||||
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
|
||||
@@ -391,7 +392,6 @@ checksums:
|
||||
common/survey_id: 08303e98b3d4134947256e494b0c829e
|
||||
common/survey_languages: 93e4a10ab190e6b1e1f7fe5f702df249
|
||||
common/survey_live: d1f370505c67509e7b2759952daba20d
|
||||
common/survey_not_found: 0485ea98d13a414eeefc8f1118b9c293
|
||||
common/survey_paused: c770d174d6b57e8425a54906a09c8b39
|
||||
common/survey_type: 417fcfecf8eaedefc4f11172426811f9
|
||||
common/surveys: 33f68ad4111b32a6361beb9d5c184533
|
||||
@@ -406,7 +406,6 @@ checksums:
|
||||
common/team_name: 549d949de4b9adad4afd6427a60a329e
|
||||
common/team_role: 66db395781aef64ef3791417b3b67c0b
|
||||
common/teams: b63448c05270497973ac4407047dae02
|
||||
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
|
||||
common/text: 4ddccc1974775ed7357f9beaf9361cec
|
||||
common/time: b504a03d52e8001bfdc5cb6205364f42
|
||||
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
|
||||
@@ -430,7 +429,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
|
||||
@@ -445,15 +443,13 @@ checksums:
|
||||
common/website_survey: 17513d25a07b6361768a15ec622b021b
|
||||
common/weeks: 545de30df4f44d3f6d1d344af6a10815
|
||||
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
|
||||
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
|
||||
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
|
||||
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
|
||||
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
|
||||
common/workspace_id: bafef925e1b57b52a69844fdf47aac3c
|
||||
common/workspace_name: 14c04a902a874ab5ddbe9cf369ef0414
|
||||
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
|
||||
common/workspace_not_found: 038fb0aaf3570610f4377b9eaed13752
|
||||
common/workspace_permission_not_found: e94bdff8af51175c5767714f82bb4833
|
||||
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
|
||||
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
|
||||
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
|
||||
@@ -629,7 +625,6 @@ checksums:
|
||||
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
|
||||
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
|
||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||
@@ -809,8 +804,14 @@ checksums:
|
||||
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
|
||||
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
|
||||
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
|
||||
environments/integrations/webhooks/endpoint_bad_gateway_error: 48ab17e9a77030b289ec22f497f50b63
|
||||
environments/integrations/webhooks/endpoint_gateway_timeout_error: 5da45e2f6933927d1f8b0aaa9566e6a6
|
||||
environments/integrations/webhooks/endpoint_internal_server_error: 6773fc34349febf95475cde88d8ee072
|
||||
environments/integrations/webhooks/endpoint_method_not_allowed_error: 9963b503311393f4d7bffae9df46d422
|
||||
environments/integrations/webhooks/endpoint_not_found_error: 607b75b7b7aa92ca81fe44e466f7c318
|
||||
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
||||
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
||||
environments/integrations/webhooks/endpoint_service_unavailable_error: f9d4874c322f2963f5afaede354c9416
|
||||
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
|
||||
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
|
||||
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
|
||||
@@ -1067,6 +1068,13 @@ checksums:
|
||||
environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112
|
||||
environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea
|
||||
environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43
|
||||
environments/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
|
||||
environments/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
|
||||
environments/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
|
||||
environments/settings/general/ai_enabled_description: e8c3e9f362588898a6cea85e18c013a1
|
||||
environments/settings/general/ai_settings_updated_successfully: 2a6f534dc3a246ced46becd8a4a9543d
|
||||
environments/settings/general/ai_smart_tools_enabled: 1dda984f5262c5f9120ee9a409236758
|
||||
environments/settings/general/ai_smart_tools_enabled_description: 1ceca6707746d3ab4a530712a06d91da
|
||||
environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
|
||||
environments/settings/general/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba
|
||||
environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b
|
||||
@@ -1608,6 +1616,8 @@ checksums:
|
||||
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
|
||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
|
||||
environments/surveys/edit/reverse_order_occasionally: 170fd50de940f382fa2e605228e4e088
|
||||
environments/surveys/edit/reverse_order_occasionally_except_last: 1c833001b940f1419dd7534b199a0b4a
|
||||
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
|
||||
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
|
||||
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
|
||||
@@ -2454,8 +2464,8 @@ checksums:
|
||||
templates/csat_question_1_headline: bd4894e95695ce5bc9fc5d326c79bc90
|
||||
templates/csat_question_1_lower_label: 54d464343c0bc17231fd51aa2d73623f
|
||||
templates/csat_question_1_upper_label: 9f000f63949d875ae628fc354a2a7f6a
|
||||
templates/csat_question_2_choice_1: a0cf57bc571c95c43924a3c641d1355e
|
||||
templates/csat_question_2_choice_2: a3a49eb9cc86972bce6dc41a107f472d
|
||||
templates/csat_question_2_choice_1: 0cb1260dd25e94f56c2da7ab21b0e0ae
|
||||
templates/csat_question_2_choice_2: f12ed9d98c7965ab949efcc25f8ca85e
|
||||
templates/csat_question_2_choice_3: a7c58d9b8afdaefadeb1f5fdf4d5ad3f
|
||||
templates/csat_question_2_choice_4: d09723c4bc1d85d99c2a9248ed0d4578
|
||||
templates/csat_question_2_choice_5: a89ca2602a3322e89adf17b3349e03ab
|
||||
@@ -3177,14 +3187,3 @@ checksums:
|
||||
templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682
|
||||
templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1
|
||||
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
|
||||
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
|
||||
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
|
||||
workflows/follow_up_label: ead918852c5840636a14baabfe94821e
|
||||
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
|
||||
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
|
||||
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
|
||||
workflows/placeholder: f5d943582bf25e8734930844e598457b
|
||||
workflows/subheading: ebf5e3b3aeb85e13e843358cc5476f42
|
||||
workflows/submit_button: 7a062f2de02ce60b1d73e510ff1ca094
|
||||
workflows/thank_you_description: 7623c1ba4f059c8d9e68aae3360b20b1
|
||||
workflows/thank_you_title: 07edd8c50685a52c0969d711df26d768
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { upsertAccount } from "./service";
|
||||
|
||||
const { mockUpsert } = vi.hoisted(() => ({
|
||||
mockUpsert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
account: {
|
||||
upsert: mockUpsert,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("account service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("upsertAccount keeps user ownership immutable on update", async () => {
|
||||
const accountData = {
|
||||
userId: "user-1",
|
||||
type: "oauth",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-1",
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_at: 123,
|
||||
scope: "openid email",
|
||||
token_type: "Bearer",
|
||||
id_token: "id-token",
|
||||
};
|
||||
|
||||
mockUpsert.mockResolvedValue({
|
||||
id: "account-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...accountData,
|
||||
});
|
||||
|
||||
await upsertAccount(accountData);
|
||||
|
||||
expect(mockUpsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: "google",
|
||||
providerAccountId: "provider-1",
|
||||
},
|
||||
},
|
||||
create: accountData,
|
||||
update: {
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_at: 123,
|
||||
scope: "openid email",
|
||||
token_type: "Bearer",
|
||||
id_token: "id-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("upsertAccount wraps Prisma known request errors", async () => {
|
||||
const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
|
||||
message: "duplicate account",
|
||||
});
|
||||
|
||||
mockUpsert.mockRejectedValue(prismaError);
|
||||
|
||||
await expect(
|
||||
upsertAccount({
|
||||
userId: "user-1",
|
||||
type: "oauth",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-1",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
message: "duplicate account",
|
||||
});
|
||||
});
|
||||
|
||||
test("upsertAccount rethrows non-Prisma errors", async () => {
|
||||
const error = new Error("unexpected failure");
|
||||
mockUpsert.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
upsertAccount({
|
||||
userId: "user-1",
|
||||
type: "oauth",
|
||||
provider: "google",
|
||||
providerAccountId: "provider-1",
|
||||
})
|
||||
).rejects.toThrow("unexpected failure");
|
||||
});
|
||||
});
|
||||
@@ -20,3 +20,36 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
|
||||
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
|
||||
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
|
||||
access_token: validatedAccountData.access_token,
|
||||
refresh_token: validatedAccountData.refresh_token,
|
||||
expires_at: validatedAccountData.expires_at,
|
||||
scope: validatedAccountData.scope,
|
||||
token_type: validatedAccountData.token_type,
|
||||
id_token: validatedAccountData.id_token,
|
||||
};
|
||||
|
||||
try {
|
||||
const account = await prisma.account.upsert({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: validatedAccountData.provider,
|
||||
providerAccountId: validatedAccountData.providerAccountId,
|
||||
},
|
||||
},
|
||||
create: validatedAccountData,
|
||||
update: updateAccountData,
|
||||
});
|
||||
|
||||
return account;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,7 +26,10 @@ export const TERMS_URL = env.TERMS_URL;
|
||||
export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
|
||||
|
||||
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
|
||||
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
|
||||
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
|
||||
export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES;
|
||||
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
|
||||
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
|
||||
@@ -152,6 +155,7 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
|
||||
export const REDIS_URL = env.REDIS_URL;
|
||||
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1";
|
||||
|
||||
export const BREVO_API_KEY = env.BREVO_API_KEY;
|
||||
export const BREVO_LIST_ID = env.BREVO_LIST_ID;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const ORIGINAL_ENV = process.env;
|
||||
|
||||
const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
|
||||
process.env = {
|
||||
...ORIGINAL_ENV,
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: "https://example.com/db",
|
||||
ENCRYPTION_KEY: "12345678901234567890123456789012",
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
describe("env", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
test("uses the default password reset token lifetime when env var is not set", async () => {
|
||||
setTestEnv({
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: undefined,
|
||||
});
|
||||
|
||||
const { env } = await import("./env");
|
||||
|
||||
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(30);
|
||||
});
|
||||
|
||||
test("uses the configured password reset token lifetime", async () => {
|
||||
setTestEnv({
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "45",
|
||||
});
|
||||
|
||||
const { env } = await import("./env");
|
||||
|
||||
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(45);
|
||||
});
|
||||
|
||||
test("fails to load when the password reset token lifetime is not an integer", async () => {
|
||||
setTestEnv({
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "30minutes",
|
||||
});
|
||||
|
||||
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
|
||||
});
|
||||
|
||||
test("fails to load when the password reset token lifetime is out of range", async () => {
|
||||
setTestEnv({
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "121",
|
||||
});
|
||||
|
||||
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
|
||||
});
|
||||
|
||||
test("allows enabling DEBUG_SHOW_RESET_LINK", async () => {
|
||||
setTestEnv({
|
||||
DEBUG_SHOW_RESET_LINK: "1",
|
||||
});
|
||||
|
||||
const { env } = await import("./env");
|
||||
|
||||
expect(env.DEBUG_SHOW_RESET_LINK).toBe("1");
|
||||
});
|
||||
|
||||
test("fails to load when DEBUG_SHOW_RESET_LINK is invalid", async () => {
|
||||
setTestEnv({
|
||||
DEBUG_SHOW_RESET_LINK: "true",
|
||||
});
|
||||
|
||||
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,9 @@ export const env = createEnv({
|
||||
BREVO_API_KEY: z.string().optional(),
|
||||
BREVO_LIST_ID: z.string().optional(),
|
||||
DATABASE_URL: z.url(),
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
|
||||
DEBUG: z.enum(["1", "0"]).optional(),
|
||||
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
|
||||
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
|
||||
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
|
||||
E2E_TESTING: z.enum(["1", "0"]).optional(),
|
||||
@@ -60,11 +62,13 @@ export const env = createEnv({
|
||||
? z.string().optional()
|
||||
: z.url("REDIS_URL is required for caching, rate limiting, and audit logging"),
|
||||
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: z.coerce.number().int().min(5).max(120).optional().default(30),
|
||||
PRIVACY_URL: z
|
||||
.url()
|
||||
.optional()
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
RATE_LIMITING_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
S3_ACCESS_KEY: z.string().optional(),
|
||||
S3_BUCKET_NAME: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
@@ -141,7 +145,9 @@ export const env = createEnv({
|
||||
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
|
||||
CRON_SECRET: process.env.CRON_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
|
||||
DEBUG: process.env.DEBUG,
|
||||
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
|
||||
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
|
||||
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
|
||||
E2E_TESTING: process.env.E2E_TESTING,
|
||||
@@ -181,8 +187,10 @@ export const env = createEnv({
|
||||
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: process.env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
|
||||
PRIVACY_URL: process.env.PRIVACY_URL,
|
||||
RATE_LIMITING_DISABLED: process.env.RATE_LIMITING_DISABLED,
|
||||
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
|
||||
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
|
||||
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
|
||||
S3_REGION: process.env.S3_REGION,
|
||||
|
||||
@@ -84,7 +84,9 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
|
||||
|
||||
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
|
||||
if (!surveyLanguages?.length || !languageCode) return "default";
|
||||
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
|
||||
const language = surveyLanguages.find(
|
||||
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
|
||||
);
|
||||
return language?.default ? "default" : language?.language.code || "default";
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -37,7 +37,8 @@ describe("auth", () => {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
];
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
|
||||
|
||||
@@ -72,7 +72,8 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
};
|
||||
|
||||
@@ -124,7 +125,8 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
},
|
||||
];
|
||||
@@ -176,7 +178,8 @@ describe("Organization Service", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: expectedBilling,
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
};
|
||||
|
||||
@@ -235,7 +238,8 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
memberships: [{ userId: "user1" }, { userId: "user2" }],
|
||||
projects: [
|
||||
@@ -276,7 +280,8 @@ describe("Organization Service", () => {
|
||||
stripeCustomerId: null,
|
||||
usageCycleAnchor: expect.any(Date),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
whitelabel: false,
|
||||
});
|
||||
expect(prisma.organization.update).toHaveBeenCalledWith({
|
||||
|
||||
@@ -34,7 +34,8 @@ export const select = {
|
||||
stripe: true,
|
||||
},
|
||||
},
|
||||
isAIEnabled: true,
|
||||
isAISmartToolsEnabled: true,
|
||||
isAIDataAnalysisEnabled: true,
|
||||
whitelabel: true,
|
||||
} satisfies Prisma.OrganizationSelect;
|
||||
|
||||
@@ -72,7 +73,8 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
|
||||
updatedAt: organization.updatedAt,
|
||||
name: organization.name,
|
||||
billing: mapOrganizationBilling(organization.billing),
|
||||
isAIEnabled: organization.isAIEnabled,
|
||||
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
|
||||
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
|
||||
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -232,7 +232,8 @@ export const mockOrganizationOutput: TOrganization = {
|
||||
name: "mock Organization",
|
||||
createdAt: currentDate,
|
||||
updatedAt: currentDate,
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
limits: {
|
||||
|
||||
@@ -70,7 +70,8 @@ describe("User Service", () => {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "org2",
|
||||
@@ -87,7 +88,8 @@ describe("User Service", () => {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
EXPECTED_ERROR_NAMES,
|
||||
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
InvalidInputError,
|
||||
InvalidPasswordResetTokenError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
TooManyRequestsError,
|
||||
@@ -71,6 +73,7 @@ describe("isExpectedError (shared helper)", () => {
|
||||
"AuthenticationError",
|
||||
"OperationNotAllowedError",
|
||||
"TooManyRequestsError",
|
||||
"InvalidPasswordResetTokenError",
|
||||
];
|
||||
|
||||
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
|
||||
@@ -87,6 +90,7 @@ describe("isExpectedError (shared helper)", () => {
|
||||
{ ErrorClass: InvalidInputError, args: ["Invalid input"] },
|
||||
{ ErrorClass: ValidationError, args: ["Invalid data"] },
|
||||
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
|
||||
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
|
||||
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
|
||||
const error = new (ErrorClass as any)(...args);
|
||||
expect(isExpectedError(error)).toBe(true);
|
||||
@@ -174,6 +178,14 @@ describe("actionClient handleServerError", () => {
|
||||
expect(result?.serverError).toBe("Not allowed");
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("InvalidPasswordResetTokenError returns its message and is not sent to Sentry", async () => {
|
||||
const result = await executeThrowingAction(
|
||||
new InvalidPasswordResetTokenError(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE)
|
||||
);
|
||||
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unexpected errors SHOULD be reported to Sentry", () => {
|
||||
|
||||
@@ -9,6 +9,10 @@ vi.mock("node:dns", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
|
||||
}));
|
||||
|
||||
const mockResolve = vi.mocked(dns.resolve);
|
||||
const mockResolve6 = vi.mocked(dns.resolve6);
|
||||
|
||||
@@ -294,4 +298,78 @@ describe("validateWebhookUrl", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS", () => {
|
||||
test("allows private IP URLs when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("http://127.0.0.1/")).resolves.toBeUndefined();
|
||||
await expect(validateWithFlag("http://192.168.1.1/test")).resolves.toBeUndefined();
|
||||
await expect(validateWithFlag("http://10.0.0.1/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows localhost when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("http://localhost/webhook")).resolves.toBeUndefined();
|
||||
await expect(validateWithFlag("http://localhost:3333/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows localhost.localdomain when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("http://localhost.localdomain/path")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("allows hostname resolving to private IP when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
setupDnsResolution(["192.168.1.1"]);
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("https://internal.company.com/webhook")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("still rejects unresolvable hostnames when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
setupDnsResolution(null, null);
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("https://typo-gibberish.invalid/hook")).rejects.toThrow(
|
||||
"Could not resolve webhook URL hostname"
|
||||
);
|
||||
});
|
||||
|
||||
test("still rejects invalid URL format when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("not-a-url")).rejects.toThrow("Invalid webhook URL format");
|
||||
});
|
||||
|
||||
test("still rejects non-HTTP protocols when enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
|
||||
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
|
||||
await expect(validateWithFlag("ftp://192.168.1.1/")).rejects.toThrow(
|
||||
"Webhook URL must use HTTPS or HTTP protocol"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import dns from "node:dns";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
"localhost",
|
||||
@@ -139,8 +140,10 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
|
||||
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
|
||||
}
|
||||
}
|
||||
|
||||
// Direct IP literal — validate without DNS resolution
|
||||
@@ -149,12 +152,17 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
|
||||
if (isIPv4Literal || isIPv6Literal) {
|
||||
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
|
||||
if (isPrivateIP(ip)) {
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
|
||||
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Domain name — resolve DNS and validate every resolved IP
|
||||
let resolvedIPs: string[];
|
||||
try {
|
||||
@@ -168,9 +176,11 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
);
|
||||
}
|
||||
|
||||
for (const ip of resolvedIPs) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
|
||||
for (const ip of resolvedIPs) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+17
-27
@@ -167,6 +167,7 @@
|
||||
"connect": "Verbinden",
|
||||
"connect_formbricks": "Formbricks verbinden",
|
||||
"connected": "Verbunden",
|
||||
"contact": "Kontakt",
|
||||
"contacts": "Kontakte",
|
||||
"continue": "Weitermachen",
|
||||
"copied": "Kopiert",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(Kopie {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Bearbeiten",
|
||||
"elements": "Elemente",
|
||||
"email": "E-Mail",
|
||||
"ending_card": "Abschluss-Karte",
|
||||
"enter_url": "URL eingeben",
|
||||
"enterprise_license": "Enterprise Lizenz",
|
||||
"environment": "Umgebung",
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
"error": "Fehler",
|
||||
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Mitglieder",
|
||||
"members_and_teams": "Mitglieder & Teams",
|
||||
"membership": "Mitgliedschaft",
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadaten",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "oder",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_not_found": "Organisation nicht gefunden",
|
||||
"organization_settings": "Organisationseinstellungen",
|
||||
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
|
||||
"other": "Andere",
|
||||
"other_filters": "Weitere Filter",
|
||||
"others": "Andere",
|
||||
"overlay_color": "Overlay-Farbe",
|
||||
"overview": "Überblick",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "Antwortanzahl anzeigen",
|
||||
"shown": "Angezeigt",
|
||||
"size": "Größe",
|
||||
"skip": "Überspringen",
|
||||
"skipped": "Übersprungen",
|
||||
"skips": "Übersprungen",
|
||||
"some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "Umfrage-ID",
|
||||
"survey_languages": "Umfragesprachen",
|
||||
"survey_live": "Umfrage live",
|
||||
"survey_not_found": "Umfrage nicht gefunden",
|
||||
"survey_paused": "Umfrage pausiert.",
|
||||
"survey_type": "Umfragetyp",
|
||||
"surveys": "Umfragen",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "Teamname",
|
||||
"team_role": "Team-Rolle",
|
||||
"teams": "Teams",
|
||||
"teams_not_found": "Teams nicht gefunden",
|
||||
"terms_of_service": "Nutzungsbedingungen",
|
||||
"text": "Text",
|
||||
"time": "Zeit",
|
||||
"time_to_finish": "Zeit zum Fertigstellen",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "Benutzer",
|
||||
"user_id": "Benutzer-ID",
|
||||
"user_not_found": "Benutzer nicht gefunden",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variablen-IDs",
|
||||
"variables": "Variablen",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "Website-Umfrage",
|
||||
"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",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "Passwort ändern",
|
||||
"forgot_password_email_did_not_request": "Wenn Du sie nicht angefordert hast, ignoriere bitte diese E-Mail.",
|
||||
"forgot_password_email_heading": "Passwort ändern",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden gültig.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Der Link ist {minutes} Minuten gültig.",
|
||||
"forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück",
|
||||
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
|
||||
"hidden_field": "Verstecktes Feld",
|
||||
@@ -665,7 +662,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",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Datenanreicherung & Analyse (KI)",
|
||||
"ai_data_analysis_enabled_description": "KI, um mehr aus deinen Daten herauszuholen, Dashboards, Diagramme, Berichte und mehr einzurichten. Greift auf deine Erfahrungsdaten zu.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
|
||||
"ai_settings_updated_successfully": "KI-Einstellungen erfolgreich aktualisiert",
|
||||
"ai_smart_tools_enabled": "Intelligente Funktionen (KI)",
|
||||
"ai_smart_tools_enabled_description": "KI, um dir zu helfen, in kürzerer Zeit mehr zu erreichen. Greift niemals auf mit Formbricks gesammelte Daten zu. Wird nur verwendet, um z. B. Umfragen in andere Sprachen zu übersetzen.",
|
||||
"bulk_invite_warning_description": "Bitte beachte, dass im Free-Plan alle Organisationsmitglieder automatisch die Rolle \"Owner\" zugewiesen bekommen, unabhängig von der im CSV-File angegebenen Rolle.",
|
||||
"cannot_delete_only_organization": "Das ist deine einzige Organisation, sie kann nicht gelöscht werden. Erstelle zuerst eine neue Organisation.",
|
||||
"cannot_leave_only_organization": "Du kannst diese Organisation nicht verlassen, da es deine einzige Organisation ist. Erstelle zuerst eine neue Organisation.",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "Wie wahrscheinlich ist es, dass Du dieses $[projectName] einem Freund oder Kollegen empfehlen würdest?",
|
||||
"csat_question_1_lower_label": "Nicht wahrscheinlich",
|
||||
"csat_question_1_upper_label": "Sehr wahrscheinlich",
|
||||
"csat_question_2_choice_1": "Etwas zufrieden",
|
||||
"csat_question_2_choice_2": "Sehr zufrieden",
|
||||
"csat_question_2_choice_1": "Sehr zufrieden",
|
||||
"csat_question_2_choice_2": "Etwas zufrieden",
|
||||
"csat_question_2_choice_3": "Weder zufrieden noch unzufrieden",
|
||||
"csat_question_2_choice_4": "Etwas unzufrieden",
|
||||
"csat_question_2_choice_5": "Sehr unzufrieden",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.",
|
||||
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
|
||||
"usability_score_name": "System Usability Score Survey (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
|
||||
"coming_soon_title": "Wir sind fast da!",
|
||||
"follow_up_label": "Möchten Sie noch etwas hinzufügen?",
|
||||
"follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?",
|
||||
"generate_button": "Workflow generieren",
|
||||
"heading": "Welchen Workflow möchtest du erstellen?",
|
||||
"placeholder": "Beschreiben Sie den Workflow, den Sie erstellen möchten…",
|
||||
"subheading": "Generiere deinen Workflow in Sekunden.",
|
||||
"submit_button": "Details hinzufügen",
|
||||
"thank_you_description": "Ihr Feedback hilft uns, die Workflows-Funktion so zu gestalten, wie Sie sie brauchen. Wir halten Sie über unseren Fortschritt auf dem Laufenden.",
|
||||
"thank_you_title": "Danke für dein Feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-30
@@ -167,6 +167,7 @@
|
||||
"connect": "Connect",
|
||||
"connect_formbricks": "Connect Formbricks",
|
||||
"connected": "Connected",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacts",
|
||||
"continue": "Continue",
|
||||
"copied": "Copied",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(copy {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Edit",
|
||||
"elements": "Elements",
|
||||
"email": "Email",
|
||||
"ending_card": "Ending card",
|
||||
"enter_url": "Enter URL",
|
||||
"enterprise_license": "Enterprise License",
|
||||
"environment": "Environment",
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You are currently in the {environment} environment.",
|
||||
"error": "Error",
|
||||
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
|
||||
@@ -261,11 +262,11 @@
|
||||
"invalid_file_type": "Invalid file type",
|
||||
"invite": "Invite",
|
||||
"invite_them": "Invite them",
|
||||
"javascript_required": "JavaScript Required",
|
||||
"javascript_required_description": "Formbricks requires JavaScript to function properly. Please enable JavaScript in your browser settings to continue.",
|
||||
"key": "Key",
|
||||
"label": "Label",
|
||||
"language": "Language",
|
||||
"javascript_required": "JavaScript Required",
|
||||
"javascript_required_description": "Formbricks requires JavaScript to function properly. Please enable JavaScript in your browser settings to continue.",
|
||||
"last_name": "Last Name",
|
||||
"learn_more": "Learn more",
|
||||
"license_expired": "License Expired",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Members",
|
||||
"members_and_teams": "Members & Teams",
|
||||
"membership": "Membership",
|
||||
"membership_not_found": "Membership not found",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadata",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Do not worry – your surveys look great on every device and screen size!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "or",
|
||||
"organization": "Organization",
|
||||
"organization_id": "Organization ID",
|
||||
"organization_not_found": "Organization not found",
|
||||
"organization_settings": "Organization settings",
|
||||
"organization_teams_not_found": "Organization teams not found",
|
||||
"other": "Other",
|
||||
"other_filters": "Other Filters",
|
||||
"others": "Others",
|
||||
"overlay_color": "Overlay color",
|
||||
"overview": "Overview",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "Show response count",
|
||||
"shown": "Shown",
|
||||
"size": "Size",
|
||||
"skip": "Skip",
|
||||
"skipped": "Skipped",
|
||||
"skips": "Skips",
|
||||
"some_files_failed_to_upload": "Some files failed to upload",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "Survey ID",
|
||||
"survey_languages": "Survey Languages",
|
||||
"survey_live": "Survey live",
|
||||
"survey_not_found": "Survey not found",
|
||||
"survey_paused": "Survey paused.",
|
||||
"survey_type": "Survey Type",
|
||||
"surveys": "Surveys",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "Team name",
|
||||
"team_role": "Team role",
|
||||
"teams": "Teams",
|
||||
"teams_not_found": "Teams not found",
|
||||
"terms_of_service": "Terms of Service",
|
||||
"text": "Text",
|
||||
"time": "Time",
|
||||
"time_to_finish": "Time to finish",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "User",
|
||||
"user_id": "User ID",
|
||||
"user_not_found": "User not found",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Variable IDs",
|
||||
"variables": "Variables",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "Website Survey",
|
||||
"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",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "Change password",
|
||||
"forgot_password_email_did_not_request": "If you did not request this, please ignore this email.",
|
||||
"forgot_password_email_heading": "Change password",
|
||||
"forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "The link is valid for {minutes} minutes.",
|
||||
"forgot_password_email_subject": "Reset your Formbricks password",
|
||||
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
|
||||
"hidden_field": "Hidden field",
|
||||
@@ -665,7 +662,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",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Unlock the full power of Formbricks. Free for 30 days."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Manage AI-powered features for this organization.",
|
||||
"ai_settings_updated_successfully": "AI settings updated successfully",
|
||||
"ai_smart_tools_enabled": "Smart functionality (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI to help you achieve more in less time. Never touches data collected with Formbricks. Only used to e.g. translate surveys to other languages.",
|
||||
"bulk_invite_warning_description": "On the free plan, all organization members are always assigned the “Owner” role.",
|
||||
"cannot_delete_only_organization": "This is your only organization, it cannot be deleted. Create a new organization first.",
|
||||
"cannot_leave_only_organization": "You cannot leave this organization as it is your only organization. Create a new organization first.",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "How likely is it that you would recommend this $[projectName] to a friend or colleague?",
|
||||
"csat_question_1_lower_label": "Not likely",
|
||||
"csat_question_1_upper_label": "Very likely",
|
||||
"csat_question_2_choice_1": "Somewhat satisfied",
|
||||
"csat_question_2_choice_2": "Very satisfied",
|
||||
"csat_question_2_choice_1": "Very satisfied",
|
||||
"csat_question_2_choice_2": "Somewhat satisfied",
|
||||
"csat_question_2_choice_3": "Neither satisfied nor dissatisfied",
|
||||
"csat_question_2_choice_4": "Somewhat dissatisfied",
|
||||
"csat_question_2_choice_5": "Very dissatisfied",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "I felt confident while using the system.",
|
||||
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
|
||||
"usability_score_name": "System Usability Score (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.",
|
||||
"coming_soon_title": "We are almost there!",
|
||||
"follow_up_label": "Is there anything else you would like to add?",
|
||||
"follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you would want included?",
|
||||
"generate_button": "Generate workflow",
|
||||
"heading": "What workflow do you want to create?",
|
||||
"placeholder": "Describe the workflow you want to generate…",
|
||||
"subheading": "Generate your workflow in seconds.",
|
||||
"submit_button": "Add details",
|
||||
"thank_you_description": "Your input helps us build the Workflows feature you actually need. We will keep you posted on our progress.",
|
||||
"thank_you_title": "Thank you for your feedback!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-27
@@ -167,6 +167,7 @@
|
||||
"connect": "Conectar",
|
||||
"connect_formbricks": "Conectar Formbricks",
|
||||
"connected": "Conectado",
|
||||
"contact": "Contacto",
|
||||
"contacts": "Contactos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(copia {copyNumber})",
|
||||
"e_commerce": "Comercio electrónico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Tarjeta final",
|
||||
"enter_url": "Introducir URL",
|
||||
"enterprise_license": "Licencia empresarial",
|
||||
"environment": "Entorno",
|
||||
"environment_not_found": "Entorno no encontrado",
|
||||
"environment_notice": "Actualmente estás en el entorno {environment}.",
|
||||
"error": "Error",
|
||||
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Miembros",
|
||||
"members_and_teams": "Miembros y equipos",
|
||||
"membership": "Membresía",
|
||||
"membership_not_found": "Membresía no encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadatos",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "No te preocupes – ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "o",
|
||||
"organization": "Organización",
|
||||
"organization_id": "ID de organización",
|
||||
"organization_not_found": "Organización no encontrada",
|
||||
"organization_settings": "Ajustes de la organización",
|
||||
"organization_teams_not_found": "Equipos de la organización no encontrados",
|
||||
"other": "Otro",
|
||||
"other_filters": "Otros Filtros",
|
||||
"others": "Otros",
|
||||
"overlay_color": "Color de superposición",
|
||||
"overview": "Resumen",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "Mostrar recuento de respuestas",
|
||||
"shown": "Mostrado",
|
||||
"size": "Tamaño",
|
||||
"skip": "Omitir",
|
||||
"skipped": "Omitido",
|
||||
"skips": "Omisiones",
|
||||
"some_files_failed_to_upload": "Algunos archivos no se han podido subir",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "ID de encuesta",
|
||||
"survey_languages": "Idiomas de la encuesta",
|
||||
"survey_live": "Encuesta activa",
|
||||
"survey_not_found": "Encuesta no encontrada",
|
||||
"survey_paused": "Encuesta pausada.",
|
||||
"survey_type": "Tipo de encuesta",
|
||||
"surveys": "Encuestas",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "Nombre del equipo",
|
||||
"team_role": "Rol del equipo",
|
||||
"teams": "Equipos",
|
||||
"teams_not_found": "Equipos no encontrados",
|
||||
"terms_of_service": "Términos de servicio",
|
||||
"text": "Texto",
|
||||
"time": "Hora",
|
||||
"time_to_finish": "Tiempo para finalizar",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "Usuario",
|
||||
"user_id": "ID de usuario",
|
||||
"user_not_found": "Usuario no encontrado",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "IDs de variables",
|
||||
"variables": "Variables",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "Encuesta de sitio web",
|
||||
"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ú",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "Cambiar contraseña",
|
||||
"forgot_password_email_did_not_request": "Si no has solicitado esto, por favor ignora este correo electrónico.",
|
||||
"forgot_password_email_heading": "Cambiar contraseña",
|
||||
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante 24 horas.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante {minutes} minutos.",
|
||||
"forgot_password_email_subject": "Restablece tu contraseña de Formbricks",
|
||||
"forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:",
|
||||
"hidden_field": "Campo oculto",
|
||||
@@ -665,7 +662,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",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
|
||||
"ai_enabled": "IA de Formbricks",
|
||||
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
|
||||
"ai_settings_updated_successfully": "Configuración de IA actualizada correctamente",
|
||||
"ai_smart_tools_enabled": "Funcionalidad inteligente (IA)",
|
||||
"ai_smart_tools_enabled_description": "IA para ayudarte a conseguir más en menos tiempo. Nunca accede a los datos recopilados con Formbricks. Solo se usa para, por ejemplo, traducir encuestas a otros idiomas.",
|
||||
"bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".",
|
||||
"cannot_delete_only_organization": "Esta es tu única organización, no se puede eliminar. Crea una nueva organización primero.",
|
||||
"cannot_leave_only_organization": "No puedes abandonar esta organización ya que es tu única organización. Crea una nueva organización primero.",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "¿Qué probabilidad hay de que recomiendes este $[projectName] a un amigo o colega?",
|
||||
"csat_question_1_lower_label": "Poco probable",
|
||||
"csat_question_1_upper_label": "Muy probable",
|
||||
"csat_question_2_choice_1": "Algo satisfecho",
|
||||
"csat_question_2_choice_2": "Muy satisfecho",
|
||||
"csat_question_2_choice_1": "Muy satisfecho",
|
||||
"csat_question_2_choice_2": "Algo satisfecho",
|
||||
"csat_question_2_choice_3": "Ni satisfecho ni insatisfecho",
|
||||
"csat_question_2_choice_4": "Algo insatisfecho",
|
||||
"csat_question_2_choice_5": "Muy insatisfecho",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "Me sentí seguro mientras usaba el sistema.",
|
||||
"usability_rating_description": "Mide la usabilidad percibida pidiendo a los usuarios que valoren su experiencia con tu producto mediante una encuesta estandarizada de 10 preguntas.",
|
||||
"usability_score_name": "Puntuación de usabilidad del sistema (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "¡Gracias por compartir tu idea de flujo de trabajo con nosotros! Actualmente estamos diseñando esta funcionalidad y tus comentarios nos ayudarán a construir exactamente lo que necesitas.",
|
||||
"coming_soon_title": "¡Ya casi estamos!",
|
||||
"follow_up_label": "¿Hay algo más que te gustaría añadir?",
|
||||
"follow_up_placeholder": "¿Qué tareas específicas te gustaría automatizar? ¿Alguna herramienta o integración que quieras incluir?",
|
||||
"generate_button": "Generar flujo de trabajo",
|
||||
"heading": "¿Qué flujo de trabajo quieres crear?",
|
||||
"placeholder": "Describe el flujo de trabajo que quieres generar…",
|
||||
"subheading": "Genera tu flujo de trabajo en segundos.",
|
||||
"submit_button": "Añadir detalles",
|
||||
"thank_you_description": "Tu aportación nos ayuda a construir la funcionalidad de Flujos de trabajo que realmente necesitas. Te mantendremos informado sobre nuestro progreso.",
|
||||
"thank_you_title": "¡Gracias por tus comentarios!"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-27
@@ -167,6 +167,7 @@
|
||||
"connect": "Connecter",
|
||||
"connect_formbricks": "Connecter Formbricks",
|
||||
"connected": "Connecté",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacts",
|
||||
"continue": "Continuer",
|
||||
"copied": "Copié",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(copie {copyNumber})",
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Modifier",
|
||||
"elements": "Éléments",
|
||||
"email": "Email",
|
||||
"ending_card": "Carte de fin",
|
||||
"enter_url": "Saisir l'URL",
|
||||
"enterprise_license": "Licence d'entreprise",
|
||||
"environment": "Environnement",
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
"error": "Erreur",
|
||||
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Membres",
|
||||
"members_and_teams": "Membres & Équipes",
|
||||
"membership": "Adhésion",
|
||||
"membership_not_found": "Abonnement non trouvé",
|
||||
"meta": "Méta",
|
||||
"metadata": "Métadonnées",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
|
||||
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "ou",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Identifiant de l'organisation",
|
||||
"organization_not_found": "Organisation non trouvée",
|
||||
"organization_settings": "Paramètres de l'organisation",
|
||||
"organization_teams_not_found": "Équipes d'organisation non trouvées",
|
||||
"other": "Autre",
|
||||
"other_filters": "Autres filtres",
|
||||
"others": "Autres",
|
||||
"overlay_color": "Couleur de superposition",
|
||||
"overview": "Aperçu",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "Afficher le nombre de réponses",
|
||||
"shown": "Montré",
|
||||
"size": "Taille",
|
||||
"skip": "Ignorer",
|
||||
"skipped": "Passé",
|
||||
"skips": "Sauter",
|
||||
"some_files_failed_to_upload": "Certains fichiers n'ont pas pu être téléchargés",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "ID de l'enquête",
|
||||
"survey_languages": "Langues de l'enquête",
|
||||
"survey_live": "Sondage en direct",
|
||||
"survey_not_found": "Sondage non trouvé",
|
||||
"survey_paused": "Sondage en pause.",
|
||||
"survey_type": "Type de sondage",
|
||||
"surveys": "Enquêtes",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "Nom de l'équipe",
|
||||
"team_role": "Rôle dans l'équipe",
|
||||
"teams": "Équipes",
|
||||
"teams_not_found": "Équipes non trouvées",
|
||||
"terms_of_service": "Conditions d'utilisation",
|
||||
"text": "Texte",
|
||||
"time": "Temps",
|
||||
"time_to_finish": "Temps de finir",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "Utilisateur",
|
||||
"user_id": "Identifiant d'utilisateur",
|
||||
"user_not_found": "Utilisateur non trouvé",
|
||||
"variable": "Variable",
|
||||
"variable_ids": "Identifiants variables",
|
||||
"variables": "Variables",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "Sondage de site web",
|
||||
"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",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "Changer le mot de passe",
|
||||
"forgot_password_email_did_not_request": "Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
|
||||
"forgot_password_email_heading": "Changer le mot de passe",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant 24 heures.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant {minutes} minutes.",
|
||||
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
|
||||
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
|
||||
"hidden_field": "Champ caché",
|
||||
@@ -665,7 +662,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",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Débloquez tout le potentiel de Formbricks. Gratuit pendant 30 jours."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
|
||||
"ai_data_analysis_enabled_description": "L'IA pour tirer le meilleur parti de vos données, configurer des tableaux de bord, des graphiques, des rapports et plus encore. Accède à vos données d'expérience.",
|
||||
"ai_enabled": "IA Formbricks",
|
||||
"ai_enabled_description": "Gérer les fonctionnalités alimentées par l'IA pour cette organisation.",
|
||||
"ai_settings_updated_successfully": "Paramètres IA mis à jour avec succès",
|
||||
"ai_smart_tools_enabled": "Fonctionnalités intelligentes (IA)",
|
||||
"ai_smart_tools_enabled_description": "L'IA pour vous aider à accomplir plus en moins de temps. N'accède jamais aux données collectées avec Formbricks. Utilisée uniquement pour, par exemple, traduire les sondages dans d'autres langues.",
|
||||
"bulk_invite_warning_description": "Dans le plan gratuit, tous les membres de l'organisation se voient toujours attribuer le rôle \"Owner\".",
|
||||
"cannot_delete_only_organization": "C'est votre seule organisation, elle ne peut pas être supprimée. Créez d'abord une nouvelle organisation.",
|
||||
"cannot_leave_only_organization": "Vous ne pouvez pas quitter cette organisation car c'est votre seule organisation. Créez d'abord une nouvelle organisation.",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "Quelle est la probabilité que vous recommandiez ce $[projectName] à un ami ou un collègue ?",
|
||||
"csat_question_1_lower_label": "Peu probable",
|
||||
"csat_question_1_upper_label": "Très probable",
|
||||
"csat_question_2_choice_1": "Un peu satisfait",
|
||||
"csat_question_2_choice_2": "Très satisfait",
|
||||
"csat_question_2_choice_1": "Très satisfait",
|
||||
"csat_question_2_choice_2": "Un peu satisfait",
|
||||
"csat_question_2_choice_3": "Ni satisfait ni insatisfait",
|
||||
"csat_question_2_choice_4": "Un peu insatisfait",
|
||||
"csat_question_2_choice_5": "Très insatisfait",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "Je me suis senti confiant en utilisant le système.",
|
||||
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
|
||||
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Merci d'avoir partagé votre idée de workflow avec nous ! Nous concevons actuellement cette fonctionnalité et vos retours nous aideront à créer exactement ce dont vous avez besoin.",
|
||||
"coming_soon_title": "Nous y sommes presque !",
|
||||
"follow_up_label": "Souhaitez-vous ajouter quelque chose ?",
|
||||
"follow_up_placeholder": "Quelles tâches spécifiques souhaitez-vous automatiser ? Y a-t-il des outils ou intégrations que vous aimeriez inclure ?",
|
||||
"generate_button": "Générer le workflow",
|
||||
"heading": "Quel workflow souhaitez-vous créer ?",
|
||||
"placeholder": "Décrivez le processus que vous souhaitez générer…",
|
||||
"subheading": "Générez votre workflow en quelques secondes.",
|
||||
"submit_button": "Ajouter des détails",
|
||||
"thank_you_description": "Vos retours nous aident à construire la fonctionnalité Workflows dont vous avez réellement besoin. Nous vous tiendrons informé(e) de notre avancement.",
|
||||
"thank_you_title": "Merci pour vos retours !"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-27
@@ -167,6 +167,7 @@
|
||||
"connect": "Kapcsolódás",
|
||||
"connect_formbricks": "Kapcsolódás a Formbrickshez",
|
||||
"connected": "Kapcsolódva",
|
||||
"contact": "Kapcsolat",
|
||||
"contacts": "Partnerek",
|
||||
"continue": "Folytatás",
|
||||
"copied": "Másolva",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "({copyNumber}. másolat)",
|
||||
"e_commerce": "E-kereskedelem",
|
||||
"edit": "Szerkesztés",
|
||||
"elements": "Elemek",
|
||||
"email": "E-mail",
|
||||
"ending_card": "Befejező kártya",
|
||||
"enter_url": "URL megadása",
|
||||
"enterprise_license": "Vállalati licenc",
|
||||
"environment": "Környezet",
|
||||
"environment_not_found": "A környezet nem található",
|
||||
"environment_notice": "Ön jelenleg a(z) {environment} környezetben van.",
|
||||
"error": "Hiba",
|
||||
"error_component_description": "Ez az erőforrás nem létezik, vagy nem rendelkezik a hozzáféréshez szükséges jogosultságokkal.",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Tagok",
|
||||
"members_and_teams": "Tagok és csapatok",
|
||||
"membership": "Tagság",
|
||||
"membership_not_found": "A tagság nem található",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metaadatok",
|
||||
"mobile_overlay_app_works_best_on_desktop": "A Formbricks nagyobb képernyőn működik a legjobban. A kérdőívek kezeléséhez vagy összeállításához váltson másik eszközre.",
|
||||
"mobile_overlay_surveys_look_good": "Ne aggódjon – a kérdőívei minden eszközön és képernyőméretnél remekül néznek ki!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "vagy",
|
||||
"organization": "Szervezet",
|
||||
"organization_id": "Szervezetazonosító",
|
||||
"organization_not_found": "A szervezet nem található",
|
||||
"organization_settings": "Szervezet beállításai",
|
||||
"organization_teams_not_found": "A szervezeti csapatok nem találhatók",
|
||||
"other": "Egyéb",
|
||||
"other_filters": "Egyéb szűrők",
|
||||
"others": "Mások",
|
||||
"overlay_color": "Rávetítés színe",
|
||||
"overview": "Áttekintés",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "Válaszok számának megjelenítése",
|
||||
"shown": "Megjelenítve",
|
||||
"size": "Méret",
|
||||
"skip": "Kihagyás",
|
||||
"skipped": "Kihagyva",
|
||||
"skips": "Kihagyja",
|
||||
"some_files_failed_to_upload": "Néhány fájlt nem sikerült feltölteni",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "Kérdőív-azonosító",
|
||||
"survey_languages": "Kérdőív nyelvei",
|
||||
"survey_live": "A kérdőív élő",
|
||||
"survey_not_found": "A kérdőív nem található",
|
||||
"survey_paused": "A kérdőív szüneteltetve.",
|
||||
"survey_type": "Kérdőív típusa",
|
||||
"surveys": "Kérdőívek",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "Csapat neve",
|
||||
"team_role": "Csapatszerep",
|
||||
"teams": "Csapatok",
|
||||
"teams_not_found": "A csapatok nem találhatók",
|
||||
"terms_of_service": "Felhasználási feltételek",
|
||||
"text": "Szöveg",
|
||||
"time": "Idő",
|
||||
"time_to_finish": "Idő a befejezésig",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "Felhasználó",
|
||||
"user_id": "Felhasználó-azonosító",
|
||||
"user_not_found": "A felhasználó nem található",
|
||||
"variable": "Változó",
|
||||
"variable_ids": "Változóazonosítók",
|
||||
"variables": "Változók",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "Webhely kérdőív",
|
||||
"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",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "Jelszó megváltoztatása",
|
||||
"forgot_password_email_did_not_request": "Ha Ön nem kérte ezt, akkor hagyja figyelmen kívül ezt a levelet.",
|
||||
"forgot_password_email_heading": "Jelszó megváltoztatása",
|
||||
"forgot_password_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "A hivatkozás {minutes} percig érvényes.",
|
||||
"forgot_password_email_subject": "A Formbricks-jelszó visszaállítása",
|
||||
"forgot_password_email_text": "Hivatkozást kért a jelszava megváltoztatásához. Ezt a lenti hivatkozásra kattintva teheti meg:",
|
||||
"hidden_field": "Rejtett mező",
|
||||
@@ -665,7 +662,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",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
|
||||
"ai_settings_updated_successfully": "AI beállítások sikeresen frissítve",
|
||||
"ai_smart_tools_enabled": "Intelligens funkciók (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI segítségével kevesebb idő alatt többet érhet el. Soha nem fér hozzá a Formbricks által gyűjtött adatokhoz. Csak például felmérések más nyelvekre történő fordításához használatos.",
|
||||
"bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.",
|
||||
"cannot_delete_only_organization": "Ez az egyetlen szervezete, nem lehet törölni. Először hozzon létre egy új szervezetet.",
|
||||
"cannot_leave_only_organization": "Nem hagyhatja el ezt a szervezetet, mivel ez az egyetlen szervezete. Először hozzon létre egy új szervezetet.",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "Mennyire valószínű, hogy ezt a(z) $[projectName] projektet ajánlaná egy ismerősnek vagy kollégának?",
|
||||
"csat_question_1_lower_label": "Nem valószínű",
|
||||
"csat_question_1_upper_label": "Nagyon valószínű",
|
||||
"csat_question_2_choice_1": "Valamelyest elégedett",
|
||||
"csat_question_2_choice_2": "Nagyon elégedett",
|
||||
"csat_question_2_choice_1": "Nagyon elégedett",
|
||||
"csat_question_2_choice_2": "Valamelyest elégedett",
|
||||
"csat_question_2_choice_3": "Sem elégedett, sem elégedetlen",
|
||||
"csat_question_2_choice_4": "Valamelyest elégedetlen",
|
||||
"csat_question_2_choice_5": "Nagyon elégedetlen",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "Magabiztosnak éreztem magam a rendszer használata során.",
|
||||
"usability_rating_description": "Az érzékelt használhatóság mérése arra kérve a felhasználókat, hogy értékeljék a termékkel kapcsolatos tapasztalataikat egy szabványosított, 10 kérdésből álló kérdőív használatával.",
|
||||
"usability_score_name": "Rendszer-használhatósági pontszám (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Köszönjük, hogy megosztotta velünk a munkafolyamatra vonatkozó ötletét! Jelenleg a funkció kialakításán dolgozunk, és a visszajelzése segít nekünk abban, hogy pontosan azt alkossuk meg, amire szüksége van.",
|
||||
"coming_soon_title": "Már majdnem kész vagyunk!",
|
||||
"follow_up_label": "Van még bármi egyéb, amit hozzá szeretne fűzni?",
|
||||
"follow_up_placeholder": "Milyen konkrét feladatokat szeretne automatizálni? Vannak olyan eszközök vagy integrációk, amelyeket szívesen látna a rendszerben?",
|
||||
"generate_button": "Munkafolyamat előállítása",
|
||||
"heading": "Milyen munkafolyamatot szeretne létrehozni?",
|
||||
"placeholder": "Mutassa be az előállítani kívánt munkafolyamatot…",
|
||||
"subheading": "Munkafolyamat előállítása másodpercek alatt.",
|
||||
"submit_button": "Részletek hozzáadása",
|
||||
"thank_you_description": "A visszajelzése segít nekünk abban, hogy olyan Munkafolyamatok funkciót alakítsunk ki, amelyre valóban szüksége van. Folyamatosan tájékoztatni fogjuk Önt a fejlesztés előrehaladásáról.",
|
||||
"thank_you_title": "Köszönjük a visszajelzését!"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-27
@@ -167,6 +167,7 @@
|
||||
"connect": "接続",
|
||||
"connect_formbricks": "Formbricksを接続",
|
||||
"connected": "接続済み",
|
||||
"contact": "連絡先",
|
||||
"contacts": "連絡先",
|
||||
"continue": "続行",
|
||||
"copied": "コピーしました",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(コピー {copyNumber})",
|
||||
"e_commerce": "Eコマース",
|
||||
"edit": "編集",
|
||||
"elements": "要素",
|
||||
"email": "メールアドレス",
|
||||
"ending_card": "終了カード",
|
||||
"enter_url": "URLを入力",
|
||||
"enterprise_license": "エンタープライズライセンス",
|
||||
"environment": "環境",
|
||||
"environment_not_found": "環境が見つかりません",
|
||||
"environment_notice": "現在、{environment} 環境にいます。",
|
||||
"error": "エラー",
|
||||
"error_component_description": "この リソース は 存在 しない か、アクセス する ための 必要な 権限 が ありません。",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "マーケティング",
|
||||
"members": "メンバー",
|
||||
"members_and_teams": "メンバー&チーム",
|
||||
"membership": "メンバーシップ",
|
||||
"membership_not_found": "メンバーシップが見つかりません",
|
||||
"meta": "メタ",
|
||||
"metadata": "メタデータ",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
|
||||
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "または",
|
||||
"organization": "組織",
|
||||
"organization_id": "組織ID",
|
||||
"organization_not_found": "組織が見つかりません",
|
||||
"organization_settings": "組織設定",
|
||||
"organization_teams_not_found": "組織のチームが見つかりません",
|
||||
"other": "その他",
|
||||
"other_filters": "その他のフィルター",
|
||||
"others": "その他",
|
||||
"overlay_color": "オーバーレイの色",
|
||||
"overview": "概要",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "回答数を表示",
|
||||
"shown": "表示済み",
|
||||
"size": "サイズ",
|
||||
"skip": "スキップ",
|
||||
"skipped": "スキップ済み",
|
||||
"skips": "スキップ数",
|
||||
"some_files_failed_to_upload": "一部のファイルのアップロードに失敗しました",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "フォームID",
|
||||
"survey_languages": "フォームの言語",
|
||||
"survey_live": "フォーム公開中",
|
||||
"survey_not_found": "フォームが見つかりません",
|
||||
"survey_paused": "フォームは一時停止中です。",
|
||||
"survey_type": "フォームの種類",
|
||||
"surveys": "フォーム",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "チーム名",
|
||||
"team_role": "チームの役割",
|
||||
"teams": "チーム",
|
||||
"teams_not_found": "チームが見つかりません",
|
||||
"terms_of_service": "利用規約",
|
||||
"text": "テキスト",
|
||||
"time": "時間",
|
||||
"time_to_finish": "所要時間",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "ユーザー",
|
||||
"user_id": "ユーザーID",
|
||||
"user_not_found": "ユーザーが見つかりません",
|
||||
"variable": "変数",
|
||||
"variable_ids": "変数ID",
|
||||
"variables": "変数",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "ウェブサイトフォーム",
|
||||
"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": "あなた",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "パスワードを変更",
|
||||
"forgot_password_email_did_not_request": "このリクエストに心当たりのない場合は、このメールを無視してください。",
|
||||
"forgot_password_email_heading": "パスワードを変更",
|
||||
"forgot_password_email_link_valid_for_24_hours": "このリンクは24時間有効です。",
|
||||
"forgot_password_email_link_valid_for_24_hours": "このリンクは{minutes}分間有効です。",
|
||||
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
|
||||
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
|
||||
"hidden_field": "非表示フィールド",
|
||||
@@ -665,7 +662,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": "属性を作成",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricksの全機能をアンロック。30日間無料。"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "AIを活用してデータから最大限の価値を引き出し、ダッシュボード、チャート、レポートなどを設定できます。エクスペリエンスデータに触れます。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "この組織のAI機能を管理します。",
|
||||
"ai_settings_updated_successfully": "AI設定が正常に更新されました",
|
||||
"ai_smart_tools_enabled": "スマート機能(AI)",
|
||||
"ai_smart_tools_enabled_description": "AIを活用して、より短時間でより多くのことを達成できます。Formbricksで収集されたデータには一切触れません。アンケートを他の言語に翻訳するなどの用途にのみ使用されます。",
|
||||
"bulk_invite_warning_description": "無料プランでは、すべての組織メンバーに常に「オーナー」ロールが割り当てられます。",
|
||||
"cannot_delete_only_organization": "これはあなたの唯一の組織です。削除できません。まず新しい組織を作成してください。",
|
||||
"cannot_leave_only_organization": "これはあなたの唯一の組織であるため、離れることはできません。まず新しい組織を作成してください。",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "この$[projectName]を友人や同僚に勧める可能性はどのくらいありますか?",
|
||||
"csat_question_1_lower_label": "可能性が低い",
|
||||
"csat_question_1_upper_label": "可能性が非常に高い",
|
||||
"csat_question_2_choice_1": "やや満足",
|
||||
"csat_question_2_choice_2": "非常に満足",
|
||||
"csat_question_2_choice_1": "非常に満足",
|
||||
"csat_question_2_choice_2": "やや満足",
|
||||
"csat_question_2_choice_3": "満足も不満もない",
|
||||
"csat_question_2_choice_4": "やや不満",
|
||||
"csat_question_2_choice_5": "非常に不満",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "システムを使っている間、自信がありました。",
|
||||
"usability_rating_description": "標準化された10の質問アンケートを使用して、製品に対するユーザーの体験を評価し、知覚された使いやすさを測定する。",
|
||||
"usability_score_name": "システムユーザビリティスコア(SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "ワークフローのアイデアを共有していただきありがとうございます!現在この機能を設計中で、あなたのフィードバックは私たちが必要とされる機能を構築するのに役立ちます。",
|
||||
"coming_soon_title": "もうすぐ完成です!",
|
||||
"follow_up_label": "他に追加したいことはありますか?",
|
||||
"follow_up_placeholder": "自動化したい具体的な作業や使用したいツール・連携があればご記入ください。",
|
||||
"generate_button": "ワークフローを生成",
|
||||
"heading": "どのようなワークフローを作成しますか?",
|
||||
"placeholder": "作成したいワークフローについて説明してください…",
|
||||
"subheading": "数秒でワークフローを生成します。",
|
||||
"submit_button": "詳細を追加",
|
||||
"thank_you_description": "ご入力いただいた内容は、より実用的なWorkflows機能の開発に役立てます。進捗は順次ご案内します。",
|
||||
"thank_you_title": "フィードバックありがとうございます!"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-27
@@ -167,6 +167,7 @@
|
||||
"connect": "Verbinden",
|
||||
"connect_formbricks": "Sluit Formbricks aan",
|
||||
"connected": "Aangesloten",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacten",
|
||||
"continue": "Doorgaan",
|
||||
"copied": "Gekopieerd",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(kopie {copyNumber})",
|
||||
"e_commerce": "E-commerce",
|
||||
"edit": "Bewerking",
|
||||
"elements": "Elementen",
|
||||
"email": "E-mail",
|
||||
"ending_card": "Einde kaart",
|
||||
"enter_url": "URL invoeren",
|
||||
"enterprise_license": "Enterprise-licentie",
|
||||
"environment": "Omgeving",
|
||||
"environment_not_found": "Omgeving niet gevonden",
|
||||
"environment_notice": "U bevindt zich momenteel in de {environment}-omgeving.",
|
||||
"error": "Fout",
|
||||
"error_component_description": "Deze bron bestaat niet of u beschikt niet over de benodigde toegangsrechten.",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Leden",
|
||||
"members_and_teams": "Leden & teams",
|
||||
"membership": "Lidmaatschap",
|
||||
"membership_not_found": "Lidmaatschap niet gevonden",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metagegevens",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
|
||||
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "of",
|
||||
"organization": "Organisatie",
|
||||
"organization_id": "Organisatie-ID",
|
||||
"organization_not_found": "Organisatie niet gevonden",
|
||||
"organization_settings": "Organisatie-instellingen",
|
||||
"organization_teams_not_found": "Organisatieteams niet gevonden",
|
||||
"other": "Ander",
|
||||
"other_filters": "Overige filters",
|
||||
"others": "Anderen",
|
||||
"overlay_color": "Overlaykleur",
|
||||
"overview": "Overzicht",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "Toon het aantal reacties",
|
||||
"shown": "Getoond",
|
||||
"size": "Maat",
|
||||
"skip": "Overslaan",
|
||||
"skipped": "Overgeslagen",
|
||||
"skips": "Overslaan",
|
||||
"some_files_failed_to_upload": "Sommige bestanden konden niet worden geüpload",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "Enquête-ID",
|
||||
"survey_languages": "Enquêtetalen",
|
||||
"survey_live": "Enquête live",
|
||||
"survey_not_found": "Enquête niet gevonden",
|
||||
"survey_paused": "Enquête onderbroken.",
|
||||
"survey_type": "Enquêtetype",
|
||||
"surveys": "Enquêtes",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "Teamnaam",
|
||||
"team_role": "Teamrol",
|
||||
"teams": "Teams",
|
||||
"teams_not_found": "Teams niet gevonden",
|
||||
"terms_of_service": "Gebruiksvoorwaarden",
|
||||
"text": "Tekst",
|
||||
"time": "Tijd",
|
||||
"time_to_finish": "Tijd om af te ronden",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "Gebruiker",
|
||||
"user_id": "Gebruikers-ID",
|
||||
"user_not_found": "Gebruiker niet gevonden",
|
||||
"variable": "Variabel",
|
||||
"variable_ids": "Variabele ID's",
|
||||
"variables": "Variabelen",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "Website-enquête",
|
||||
"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",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "Wachtwoord wijzigen",
|
||||
"forgot_password_email_did_not_request": "Als u dit niet heeft aangevraagd, kunt u deze e-mail negeren.",
|
||||
"forgot_password_email_heading": "Wachtwoord wijzigen",
|
||||
"forgot_password_email_link_valid_for_24_hours": "De link is 24 uur geldig.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "De link is {minutes} minuten geldig.",
|
||||
"forgot_password_email_subject": "Reset uw Formbricks-wachtwoord",
|
||||
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
|
||||
"hidden_field": "Verborgen veld",
|
||||
@@ -665,7 +662,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",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Ontgrendel de volledige kracht van Formbricks. 30 dagen gratis."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI om meer uit je data te halen, dashboards op te zetten, grafieken, rapporten en meer. Raakt je ervaringsdata aan.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Beheer AI-functies voor deze organisatie.",
|
||||
"ai_settings_updated_successfully": "AI-instellingen succesvol bijgewerkt",
|
||||
"ai_smart_tools_enabled": "Slimme functionaliteit (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI om je te helpen meer te bereiken in minder tijd. Raakt nooit data aan die met Formbricks is verzameld. Wordt alleen gebruikt om bijvoorbeeld enquêtes naar andere talen te vertalen.",
|
||||
"bulk_invite_warning_description": "Bij het gratis abonnement krijgen alle organisatieleden altijd de rol 'Eigenaar' toegewezen.",
|
||||
"cannot_delete_only_organization": "Dit is uw enige organisatie. Deze kan niet worden verwijderd. Maak eerst een nieuwe organisatie aan.",
|
||||
"cannot_leave_only_organization": "U kunt deze organisatie niet verlaten, aangezien dit uw enige organisatie is. Maak eerst een nieuwe organisatie aan.",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "Hoe waarschijnlijk is het dat u deze $[projectName] zou aanbevelen aan een vriend of collega?",
|
||||
"csat_question_1_lower_label": "Niet waarschijnlijk",
|
||||
"csat_question_1_upper_label": "Zeer waarschijnlijk",
|
||||
"csat_question_2_choice_1": "Enigszins tevreden",
|
||||
"csat_question_2_choice_2": "Zeer tevreden",
|
||||
"csat_question_2_choice_1": "Zeer tevreden",
|
||||
"csat_question_2_choice_2": "Enigszins tevreden",
|
||||
"csat_question_2_choice_3": "Noch tevreden, noch ontevreden",
|
||||
"csat_question_2_choice_4": "Enigszins ontevreden",
|
||||
"csat_question_2_choice_5": "Zeer ontevreden",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "Ik voelde me zelfverzekerd tijdens het gebruik van het systeem.",
|
||||
"usability_rating_description": "Meet de waargenomen bruikbaarheid door gebruikers te vragen hun ervaring met uw product te beoordelen met behulp van een gestandaardiseerde enquête met tien vragen.",
|
||||
"usability_score_name": "Systeembruikbaarheidsscore (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Bedankt voor het delen van je workflow-idee met ons! We zijn momenteel bezig met het ontwerpen van deze functie en jouw feedback helpt ons om precies te bouwen wat je nodig hebt.",
|
||||
"coming_soon_title": "We zijn er bijna!",
|
||||
"follow_up_label": "Is er nog iets dat u wilt toevoegen?",
|
||||
"follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je wilt meenemen?",
|
||||
"generate_button": "Genereer workflow",
|
||||
"heading": "Welke workflow wil je maken?",
|
||||
"placeholder": "Beschrijf de workflow die je wilt genereren…",
|
||||
"subheading": "Genereer je workflow in enkele seconden.",
|
||||
"submit_button": "Voeg details toe",
|
||||
"thank_you_description": "Jouw input helpt ons om de Workflows-functie te bouwen die jij echt nodig hebt. We houden je op de hoogte van onze voortgang.",
|
||||
"thank_you_title": "Bedankt voor je feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-27
@@ -167,6 +167,7 @@
|
||||
"connect": "Conectar",
|
||||
"connect_formbricks": "Conectar Formbricks",
|
||||
"connected": "conectado",
|
||||
"contact": "Contato",
|
||||
"contacts": "Contatos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(cópia {copyNumber})",
|
||||
"e_commerce": "comércio eletrônico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Inserir URL",
|
||||
"enterprise_license": "Licença Empresarial",
|
||||
"environment": "Ambiente",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "marketing",
|
||||
"members": "Membros",
|
||||
"members_and_teams": "Membros e equipes",
|
||||
"membership": "Associação",
|
||||
"membership_not_found": "Assinatura não encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "metadados",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "ou",
|
||||
"organization": "organização",
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_not_found": "Organização não encontrada",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"organization_teams_not_found": "Equipes da organização não encontradas",
|
||||
"other": "outro",
|
||||
"other_filters": "Outros Filtros",
|
||||
"others": "Outros",
|
||||
"overlay_color": "Cor da sobreposição",
|
||||
"overview": "Visão Geral",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "Mostrar contagem de respostas",
|
||||
"shown": "mostrado",
|
||||
"size": "Tamanho",
|
||||
"skip": "Pular",
|
||||
"skipped": "Pulou",
|
||||
"skips": "Pula",
|
||||
"some_files_failed_to_upload": "Alguns arquivos falharam ao enviar",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "ID da Pesquisa",
|
||||
"survey_languages": "Idiomas da Pesquisa",
|
||||
"survey_live": "Pesquisa ao vivo",
|
||||
"survey_not_found": "Pesquisa não encontrada",
|
||||
"survey_paused": "Pesquisa pausada.",
|
||||
"survey_type": "Tipo de Pesquisa",
|
||||
"surveys": "Pesquisas",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "Nome da equipe",
|
||||
"team_role": "Função na equipe",
|
||||
"teams": "Equipes",
|
||||
"teams_not_found": "Equipes não encontradas",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"text": "Texto",
|
||||
"time": "tempo",
|
||||
"time_to_finish": "Hora de terminar",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "Usuário",
|
||||
"user_id": "ID do usuário",
|
||||
"user_not_found": "Usuário não encontrado",
|
||||
"variable": "variável",
|
||||
"variable_ids": "IDs de variáveis",
|
||||
"variables": "Variáveis",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "Pesquisa de Site",
|
||||
"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ê",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "Mudar senha",
|
||||
"forgot_password_email_did_not_request": "Se você não solicitou isso, por favor ignore este e-mail.",
|
||||
"forgot_password_email_heading": "Mudar senha",
|
||||
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "O link é válido por {minutes} minutos.",
|
||||
"forgot_password_email_subject": "Redefinir sua senha Formbricks",
|
||||
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
|
||||
"hidden_field": "Campo oculto",
|
||||
@@ -665,7 +662,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",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para extrair mais dos seus dados, configurar dashboards, gráficos, relatórios e muito mais. Acessa os dados da sua experiência.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Gerencie recursos com IA para esta organização.",
|
||||
"ai_settings_updated_successfully": "Configurações de IA atualizadas com sucesso",
|
||||
"ai_smart_tools_enabled": "Funcionalidades inteligentes (IA)",
|
||||
"ai_smart_tools_enabled_description": "IA para ajudar você a conquistar mais em menos tempo. Nunca acessa dados coletados com o Formbricks. Usado apenas para, por exemplo, traduzir pesquisas para outros idiomas.",
|
||||
"bulk_invite_warning_description": "Por favor, note que no Plano Gratuito, todos os membros da organização são automaticamente atribuídos ao papel de 'Owner', independentemente do papel especificado no arquivo CSV.",
|
||||
"cannot_delete_only_organization": "Essa é sua única organização, não pode ser deletada. Crie uma nova organização primeiro.",
|
||||
"cannot_leave_only_organization": "Você não pode sair dessa organização porque é a sua única. Crie uma nova organização primeiro.",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "Qual a probabilidade de você recomendar este $[projectName] para um amigo ou colega?",
|
||||
"csat_question_1_lower_label": "Pouco provável",
|
||||
"csat_question_1_upper_label": "Muito provável",
|
||||
"csat_question_2_choice_1": "Meio satisfeito",
|
||||
"csat_question_2_choice_2": "Muito satisfeito",
|
||||
"csat_question_2_choice_1": "Muito satisfeito",
|
||||
"csat_question_2_choice_2": "Meio satisfeito",
|
||||
"csat_question_2_choice_3": "Nem satisfeito nem insatisfeito",
|
||||
"csat_question_2_choice_4": "Um pouco insatisfeito",
|
||||
"csat_question_2_choice_5": "Muito insatisfeito",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "Me senti confiante ao usar o sistema.",
|
||||
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
|
||||
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Obrigado por compartilhar sua ideia de fluxo de trabalho conosco! Estamos atualmente projetando este recurso e seu feedback nos ajudará a construir exatamente o que você precisa.",
|
||||
"coming_soon_title": "Estamos quase lá!",
|
||||
"follow_up_label": "Há algo mais que você gostaria de acrescentar?",
|
||||
"follow_up_placeholder": "Quais tarefas específicas você gostaria de automatizar? Alguma ferramenta ou integração que gostaria de incluir?",
|
||||
"generate_button": "Gerar fluxo de trabalho",
|
||||
"heading": "Qual fluxo de trabalho você quer criar?",
|
||||
"placeholder": "Descreva o fluxo de trabalho que você quer gerar…",
|
||||
"subheading": "Gere seu fluxo de trabalho em segundos.",
|
||||
"submit_button": "Adicionar detalhes",
|
||||
"thank_you_description": "Sua contribuição nos ajuda a construir a funcionalidade de Fluxos de Trabalho que você realmente precisa. Manteremos você informado sobre nosso progresso.",
|
||||
"thank_you_title": "Obrigado pelo seu feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-27
@@ -167,6 +167,7 @@
|
||||
"connect": "Conectar",
|
||||
"connect_formbricks": "Ligar Formbricks",
|
||||
"connected": "Conectado",
|
||||
"contact": "Contacto",
|
||||
"contacts": "Contactos",
|
||||
"continue": "Continuar",
|
||||
"copied": "Copiado",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(cópia {copyNumber})",
|
||||
"e_commerce": "Comércio Eletrónico",
|
||||
"edit": "Editar",
|
||||
"elements": "Elementos",
|
||||
"email": "Email",
|
||||
"ending_card": "Cartão de encerramento",
|
||||
"enter_url": "Introduzir URL",
|
||||
"enterprise_license": "Licença Enterprise",
|
||||
"environment": "Ambiente",
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Membros",
|
||||
"members_and_teams": "Membros e equipas",
|
||||
"membership": "Subscrição",
|
||||
"membership_not_found": "Associação não encontrada",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadados",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "ou",
|
||||
"organization": "Organização",
|
||||
"organization_id": "ID da Organização",
|
||||
"organization_not_found": "Organização não encontrada",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"organization_teams_not_found": "Equipas da organização não encontradas",
|
||||
"other": "Outro",
|
||||
"other_filters": "Outros Filtros",
|
||||
"others": "Outros",
|
||||
"overlay_color": "Cor da sobreposição",
|
||||
"overview": "Visão geral",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "Mostrar contagem de respostas",
|
||||
"shown": "Mostrado",
|
||||
"size": "Tamanho",
|
||||
"skip": "Saltar",
|
||||
"skipped": "Ignorado",
|
||||
"skips": "Saltos",
|
||||
"some_files_failed_to_upload": "Alguns ficheiros falharam ao carregar",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "ID do Inquérito",
|
||||
"survey_languages": "Idiomas da Pesquisa",
|
||||
"survey_live": "Inquérito ao vivo",
|
||||
"survey_not_found": "Inquérito não encontrado",
|
||||
"survey_paused": "Inquérito pausado.",
|
||||
"survey_type": "Tipo de Inquérito",
|
||||
"surveys": "Inquéritos",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "Nome da equipa",
|
||||
"team_role": "Função na equipa",
|
||||
"teams": "Equipas",
|
||||
"teams_not_found": "Equipas não encontradas",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"text": "Texto",
|
||||
"time": "Tempo",
|
||||
"time_to_finish": "Tempo para concluir",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "Utilizador",
|
||||
"user_id": "ID do Utilizador",
|
||||
"user_not_found": "Utilizador não encontrado",
|
||||
"variable": "Variável",
|
||||
"variable_ids": "IDs de variáveis",
|
||||
"variables": "Variáveis",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "Inquérito do Website",
|
||||
"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ê",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "Alterar palavra-passe",
|
||||
"forgot_password_email_did_not_request": "Se não solicitou isto, por favor ignore este email.",
|
||||
"forgot_password_email_heading": "Alterar palavra-passe",
|
||||
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "O link é válido por {minutes} minutos.",
|
||||
"forgot_password_email_subject": "Redefina a sua palavra-passe do Formbricks",
|
||||
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
|
||||
"hidden_field": "Campo oculto",
|
||||
@@ -665,7 +662,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",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
|
||||
"ai_data_analysis_enabled_description": "IA para tirar mais partido dos teus dados, configurar dashboards, gráficos, relatórios e muito mais. Acede aos dados da tua experiência.",
|
||||
"ai_enabled": "IA da Formbricks",
|
||||
"ai_enabled_description": "Gerir funcionalidades com IA para esta organização.",
|
||||
"ai_settings_updated_successfully": "Definições de IA atualizadas com sucesso",
|
||||
"ai_smart_tools_enabled": "Funcionalidade inteligente (IA)",
|
||||
"ai_smart_tools_enabled_description": "IA para te ajudar a alcançar mais em menos tempo. Nunca acede aos dados recolhidos com o Formbricks. Apenas usado para, por exemplo, traduzir inquéritos para outros idiomas.",
|
||||
"bulk_invite_warning_description": "No plano gratuito, todos os membros da organização são sempre atribuídos ao papel de \"Proprietário\".",
|
||||
"cannot_delete_only_organization": "Esta é a sua única organização, não pode ser eliminada. Crie uma nova organização primeiro.",
|
||||
"cannot_leave_only_organization": "Não pode sair desta organização, pois é a sua única organização. Crie uma nova organização primeiro.",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "Qual a probabilidade de recomendar este $[projectName] a um amigo ou colega?",
|
||||
"csat_question_1_lower_label": "Pouco provável",
|
||||
"csat_question_1_upper_label": "Muito provável",
|
||||
"csat_question_2_choice_1": "Algo satisfeito",
|
||||
"csat_question_2_choice_2": "Muito satisfeito",
|
||||
"csat_question_2_choice_1": "Muito satisfeito",
|
||||
"csat_question_2_choice_2": "Algo satisfeito",
|
||||
"csat_question_2_choice_3": "Nem satisfeito nem insatisfeito",
|
||||
"csat_question_2_choice_4": "Algo insatisfeito",
|
||||
"csat_question_2_choice_5": "Muito insatisfeito",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "Eu senti-me confiante ao usar o sistema.",
|
||||
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
|
||||
"usability_score_name": "System Usability Score (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Obrigado por partilhar a sua ideia de fluxo de trabalho connosco! Estamos atualmente a desenhar esta funcionalidade e o seu feedback vai ajudar-nos a construir exatamente o que precisa.",
|
||||
"coming_soon_title": "Estamos quase lá!",
|
||||
"follow_up_label": "Há mais alguma coisa que gostaria de acrescentar?",
|
||||
"follow_up_placeholder": "Que tarefas específicas gostaria de automatizar? Existe alguma ferramenta ou integração que queira incluir?",
|
||||
"generate_button": "Gerar fluxo de trabalho",
|
||||
"heading": "Que fluxo de trabalho quer criar?",
|
||||
"placeholder": "Descreva o fluxo de trabalho que pretende gerar…",
|
||||
"subheading": "Gere o seu fluxo de trabalho em segundos.",
|
||||
"submit_button": "Adicionar detalhes",
|
||||
"thank_you_description": "A sua contribuição ajuda-nos a criar a funcionalidade Workflows de que realmente precisa. Mantê-lo-emos informado sobre o nosso progresso.",
|
||||
"thank_you_title": "Obrigado pelo seu feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-27
@@ -167,6 +167,7 @@
|
||||
"connect": "Conectează",
|
||||
"connect_formbricks": "Conectează Formbricks",
|
||||
"connected": "Conectat",
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacte",
|
||||
"continue": "Continuă",
|
||||
"copied": "Copiat",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(copie {copyNumber})",
|
||||
"e_commerce": "Comerț electronic",
|
||||
"edit": "Editare",
|
||||
"elements": "Elemente",
|
||||
"email": "Email",
|
||||
"ending_card": "Cardul de finalizare",
|
||||
"enter_url": "Introduceți URL-ul",
|
||||
"enterprise_license": "Licență Întreprindere",
|
||||
"environment": "Mediu",
|
||||
"environment_not_found": "Mediul nu a fost găsit",
|
||||
"environment_notice": "Te afli în prezent în mediul {environment}",
|
||||
"error": "Eroare",
|
||||
"error_component_description": "Această resursă nu există sau nu aveți drepturile necesare pentru a o accesa.",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "Marketing",
|
||||
"members": "Membri",
|
||||
"members_and_teams": "Membri și echipe",
|
||||
"membership": "Abonament",
|
||||
"membership_not_found": "Apartenența nu a fost găsită",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadate",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
|
||||
"mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "sau",
|
||||
"organization": "Organizație",
|
||||
"organization_id": "ID Organizație",
|
||||
"organization_not_found": "Organizația nu a fost găsită",
|
||||
"organization_settings": "Setări Organizație",
|
||||
"organization_teams_not_found": "Echipele organizației nu au fost găsite",
|
||||
"other": "Altele",
|
||||
"other_filters": "Alte Filtre",
|
||||
"others": "Altele",
|
||||
"overlay_color": "Culoare overlay",
|
||||
"overview": "Prezentare generală",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "Afișează numărul de răspunsuri",
|
||||
"shown": "Afișat",
|
||||
"size": "Mărime",
|
||||
"skip": "Omite",
|
||||
"skipped": "Sărit",
|
||||
"skips": "Salturi",
|
||||
"some_files_failed_to_upload": "Unele fișiere nu au reușit să se încarce",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "ID Chestionar",
|
||||
"survey_languages": "Limbi chestionar",
|
||||
"survey_live": "Chestionar activ",
|
||||
"survey_not_found": "Sondajul nu a fost găsit",
|
||||
"survey_paused": "Chestionar oprit.",
|
||||
"survey_type": "Tip Chestionar",
|
||||
"surveys": "Sondaje",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "Nume echipă",
|
||||
"team_role": "Rol în echipă",
|
||||
"teams": "Echipe",
|
||||
"teams_not_found": "Echipele nu au fost găsite",
|
||||
"terms_of_service": "Termeni și condiții",
|
||||
"text": "Text",
|
||||
"time": "Timp",
|
||||
"time_to_finish": "Timp până la finalizare",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "Utilizator",
|
||||
"user_id": "ID Utilizator",
|
||||
"user_not_found": "Utilizatorul nu a fost găsit",
|
||||
"variable": "Variabilă",
|
||||
"variable_ids": "ID-uri variabile",
|
||||
"variables": "Variante",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "Chestionar despre site",
|
||||
"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",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "Schimbați parola",
|
||||
"forgot_password_email_did_not_request": "Dacă nu ați solicitat acest lucru, vă rugăm să ignorați acest email.",
|
||||
"forgot_password_email_heading": "Schimbați parola",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de 24 de ore.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de {minutes} minute.",
|
||||
"forgot_password_email_subject": "Resetați parola dumneavoastră Formbricks",
|
||||
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
|
||||
"hidden_field": "Câmp ascuns",
|
||||
@@ -665,7 +662,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",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Deblocați puterea completă a Formbricks. Gratuit timp de 30 de zile."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI pentru a obține mai mult din datele tale, configurare dashboard-uri, grafice, rapoarte și multe altele. Accesează datele tale de experiență.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Gestionează funcționalitățile bazate pe AI pentru această organizație.",
|
||||
"ai_settings_updated_successfully": "Setările AI au fost actualizate cu succes",
|
||||
"ai_smart_tools_enabled": "Funcționalitate inteligentă (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI care te ajută să faci mai mult în mai puțin timp. Nu accesează niciodată datele colectate cu Formbricks. Folosit doar, de exemplu, pentru a traduce chestionare în alte limbi.",
|
||||
"bulk_invite_warning_description": "În planul gratuit, toți membrii organizației sunt întotdeauna alocați rolului „Proprietar”.",
|
||||
"cannot_delete_only_organization": "Aceasta este singura ta organizație, nu poate fi ștearsă. Creează mai întâi o nouă organizație.",
|
||||
"cannot_leave_only_organization": "Nu poți părăsi această organizație deoarece este singura ta organizație. Creează mai întâi o nouă organizație.",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "Cât de probabil este ca să recomandați acest $[projectName] unui prieten sau coleg?",
|
||||
"csat_question_1_lower_label": "Puțin probabil",
|
||||
"csat_question_1_upper_label": "Foarte probabil",
|
||||
"csat_question_2_choice_1": "Puțin mulțumit",
|
||||
"csat_question_2_choice_2": "Foarte mulțumit",
|
||||
"csat_question_2_choice_1": "Foarte mulțumit",
|
||||
"csat_question_2_choice_2": "Puțin mulțumit",
|
||||
"csat_question_2_choice_3": "Nici mulțumit, nici nemulțumit",
|
||||
"csat_question_2_choice_4": "Ușor nemulțumit",
|
||||
"csat_question_2_choice_5": "Foarte nemulțumit",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "M-am simțit încrezător în timp ce utilizam sistemul.",
|
||||
"usability_rating_description": "Măsurați uzabilitatea percepută cerând utilizatorilor să își evalueze experiența cu produsul dumneavoastră folosind un chestionar standardizat din 10 întrebări.",
|
||||
"usability_score_name": "Scor de Uzabilitate al Sistemului (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Îți mulțumim că ai împărtășit cu noi ideea ta de workflow! În prezent, lucrăm la această funcționalitate, iar feedback-ul tău ne ajută să construim exact ce ai nevoie.",
|
||||
"coming_soon_title": "Suntem aproape gata!",
|
||||
"follow_up_label": "Mai este ceva ce ați dori să adăugați?",
|
||||
"follow_up_placeholder": "Ce sarcini specifice ați dori să automatizați? Există instrumente sau integrări pe care ați dori să le includem?",
|
||||
"generate_button": "Generează workflow",
|
||||
"heading": "Ce workflow vrei să creezi?",
|
||||
"placeholder": "Descrieți fluxul de lucru pe care doriți să-l generați…",
|
||||
"subheading": "Generează-ți workflow-ul în câteva secunde.",
|
||||
"submit_button": "Adaugă detalii",
|
||||
"thank_you_description": "Contribuția dvs. ne ajută să dezvoltăm funcția Workflows de care chiar aveți nevoie. Vă vom ține la curent cu progresul nostru.",
|
||||
"thank_you_title": "Îți mulțumim pentru feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-27
@@ -167,6 +167,7 @@
|
||||
"connect": "Подключить",
|
||||
"connect_formbricks": "Подключить Formbricks",
|
||||
"connected": "Подключено",
|
||||
"contact": "Контакт",
|
||||
"contacts": "Контакты",
|
||||
"continue": "Продолжить",
|
||||
"copied": "Скопировано",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(копия {copyNumber})",
|
||||
"e_commerce": "E-Commerce",
|
||||
"edit": "Редактировать",
|
||||
"elements": "Элементы",
|
||||
"email": "Email",
|
||||
"ending_card": "Завершающая карточка",
|
||||
"enter_url": "Введите URL",
|
||||
"enterprise_license": "Корпоративная лицензия",
|
||||
"environment": "Окружение",
|
||||
"environment_not_found": "Среда не найдена",
|
||||
"environment_notice": "В данный момент вы находитесь в среде {environment}.",
|
||||
"error": "Ошибка",
|
||||
"error_component_description": "Этот ресурс не существует или у вас нет необходимых прав для доступа к нему.",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "Маркетинг",
|
||||
"members": "Участники",
|
||||
"members_and_teams": "Участники и команды",
|
||||
"membership": "Членство",
|
||||
"membership_not_found": "Участие не найдено",
|
||||
"meta": "Мета",
|
||||
"metadata": "Метаданные",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
|
||||
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "или",
|
||||
"organization": "Организация",
|
||||
"organization_id": "ID организации",
|
||||
"organization_not_found": "Организация не найдена",
|
||||
"organization_settings": "Настройки организации",
|
||||
"organization_teams_not_found": "Команды организации не найдены",
|
||||
"other": "Другое",
|
||||
"other_filters": "Другие фильтры",
|
||||
"others": "Другие",
|
||||
"overlay_color": "Цвет наложения",
|
||||
"overview": "Обзор",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "Показать количество ответов",
|
||||
"shown": "Показано",
|
||||
"size": "Размер",
|
||||
"skip": "Пропустить",
|
||||
"skipped": "Пропущено",
|
||||
"skips": "Пропуски",
|
||||
"some_files_failed_to_upload": "Не удалось загрузить некоторые файлы",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "ID опроса",
|
||||
"survey_languages": "Языки опроса",
|
||||
"survey_live": "Опрос активен",
|
||||
"survey_not_found": "Опрос не найден",
|
||||
"survey_paused": "Опрос приостановлен.",
|
||||
"survey_type": "Тип опроса",
|
||||
"surveys": "Опросы",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "Название команды",
|
||||
"team_role": "Роль в команде",
|
||||
"teams": "Команды",
|
||||
"teams_not_found": "Команды не найдены",
|
||||
"terms_of_service": "Условия использования",
|
||||
"text": "Текст",
|
||||
"time": "Время",
|
||||
"time_to_finish": "Время до завершения",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "Пользователь",
|
||||
"user_id": "ID пользователя",
|
||||
"user_not_found": "Пользователь не найден",
|
||||
"variable": "Переменная",
|
||||
"variable_ids": "ID переменных",
|
||||
"variables": "Переменные",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "Опрос сайта",
|
||||
"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": "Вы",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "Сменить пароль",
|
||||
"forgot_password_email_did_not_request": "Если вы не запрашивали это, просто проигнорируйте это письмо.",
|
||||
"forgot_password_email_heading": "Сменить пароль",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Ссылка действительна в течение 24 часов.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Ссылка действительна в течение {minutes} минут.",
|
||||
"forgot_password_email_subject": "Сбросьте свой пароль Formbricks",
|
||||
"forgot_password_email_text": "Вы запросили ссылку для смены пароля. Вы можете сделать это, перейдя по ссылке ниже:",
|
||||
"hidden_field": "Скрытое поле",
|
||||
@@ -665,7 +662,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": "Создать атрибут",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Откройте все возможности Formbricks. Бесплатно на 30 дней."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
|
||||
"ai_data_analysis_enabled_description": "ИИ для получения большего от твоих данных: настройка дашбордов, графиков, отчетов и не только. Работает с твоими данными об опыте.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Управляй функциями на базе ИИ для этой организации.",
|
||||
"ai_settings_updated_successfully": "Настройки AI успешно обновлены",
|
||||
"ai_smart_tools_enabled": "Умные функции (ИИ)",
|
||||
"ai_smart_tools_enabled_description": "ИИ помогает тебе делать больше за меньшее время. Никогда не использует данные, собранные с помощью Formbricks. Применяется, например, для перевода опросов на другие языки.",
|
||||
"bulk_invite_warning_description": "В бесплатном тарифе всем участникам организации всегда назначается роль \"Владелец\".",
|
||||
"cannot_delete_only_organization": "Это ваша единственная организация, её нельзя удалить. Сначала создайте новую организацию.",
|
||||
"cannot_leave_only_organization": "Вы не можете покинуть эту организацию, так как она у вас единственная. Сначала создайте новую организацию.",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "Насколько вероятно, что вы порекомендуете $[projectName] другу или коллеге?",
|
||||
"csat_question_1_lower_label": "Маловероятно",
|
||||
"csat_question_1_upper_label": "Очень вероятно",
|
||||
"csat_question_2_choice_1": "В целом доволен",
|
||||
"csat_question_2_choice_2": "Очень доволен",
|
||||
"csat_question_2_choice_1": "Очень доволен",
|
||||
"csat_question_2_choice_2": "В целом доволен",
|
||||
"csat_question_2_choice_3": "Ни доволен, ни недоволен",
|
||||
"csat_question_2_choice_4": "В целом недоволен",
|
||||
"csat_question_2_choice_5": "Очень недоволен",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "Я чувствовал себя уверенно, используя систему.",
|
||||
"usability_rating_description": "Оцените воспринимаемую удобство, попросив пользователей оценить свой опыт работы с вашим продуктом с помощью стандартизированного опроса из 10 вопросов.",
|
||||
"usability_score_name": "Индекс удобства системы (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.",
|
||||
"coming_soon_title": "Мы почти готовы!",
|
||||
"follow_up_label": "Хотите ли вы что-нибудь добавить?",
|
||||
"follow_up_placeholder": "Какие конкретные задачи вы хотите автоматизировать? Какие инструменты или интеграции вам хотелось бы добавить?",
|
||||
"generate_button": "Сгенерировать воркфлоу",
|
||||
"heading": "Какой воркфлоу ты хочешь создать?",
|
||||
"placeholder": "Опишите, какой сценарий (workflow) вы хотите создать…",
|
||||
"subheading": "Сгенерируй свой воркфлоу за секунды.",
|
||||
"submit_button": "Добавить детали",
|
||||
"thank_you_description": "Ваш вклад помогает нам создавать функцию сценариев, которая действительно вам нужна. Мы будем держать вас в курсе нашего прогресса.",
|
||||
"thank_you_title": "Спасибо за твой отзыв!"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-27
@@ -167,6 +167,7 @@
|
||||
"connect": "Anslut",
|
||||
"connect_formbricks": "Anslut Formbricks",
|
||||
"connected": "Ansluten",
|
||||
"contact": "Kontakt",
|
||||
"contacts": "Kontakter",
|
||||
"continue": "Fortsätt",
|
||||
"copied": "Kopierad",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(kopia {copyNumber})",
|
||||
"e_commerce": "E-handel",
|
||||
"edit": "Redigera",
|
||||
"elements": "Element",
|
||||
"email": "E-post",
|
||||
"ending_card": "Avslutningskort",
|
||||
"enter_url": "Ange URL",
|
||||
"enterprise_license": "Företagslicens",
|
||||
"environment": "Miljö",
|
||||
"environment_not_found": "Miljö hittades inte",
|
||||
"environment_notice": "Du är för närvarande i {environment}-miljön.",
|
||||
"error": "Fel",
|
||||
"error_component_description": "Denna resurs finns inte eller så har du inte de nödvändiga rättigheterna för att komma åt den.",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "Marknadsföring",
|
||||
"members": "Medlemmar",
|
||||
"members_and_teams": "Medlemmar och team",
|
||||
"membership": "Medlemskap",
|
||||
"membership_not_found": "Medlemskap hittades inte",
|
||||
"meta": "Meta",
|
||||
"metadata": "Metadata",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
|
||||
"mobile_overlay_surveys_look_good": "Oroa dig inte – dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "eller",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Organisations-ID",
|
||||
"organization_not_found": "Organisation hittades inte",
|
||||
"organization_settings": "Organisationsinställningar",
|
||||
"organization_teams_not_found": "Organisationsteam hittades inte",
|
||||
"other": "Annat",
|
||||
"other_filters": "Andra filter",
|
||||
"others": "Andra",
|
||||
"overlay_color": "Overlay-färg",
|
||||
"overview": "Översikt",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "Visa antal svar",
|
||||
"shown": "Visad",
|
||||
"size": "Storlek",
|
||||
"skip": "Hoppa över",
|
||||
"skipped": "Överhoppad",
|
||||
"skips": "Överhoppningar",
|
||||
"some_files_failed_to_upload": "Några filer misslyckades att laddas upp",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "Enkät-ID",
|
||||
"survey_languages": "Enkätspråk",
|
||||
"survey_live": "Enkät live",
|
||||
"survey_not_found": "Enkät hittades inte",
|
||||
"survey_paused": "Enkät pausad.",
|
||||
"survey_type": "Enkättyp",
|
||||
"surveys": "Enkäter",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "Teamnamn",
|
||||
"team_role": "Teamroll",
|
||||
"teams": "Åtkomstkontroll",
|
||||
"teams_not_found": "Team hittades inte",
|
||||
"terms_of_service": "Användarvillkor",
|
||||
"text": "Text",
|
||||
"time": "Tid",
|
||||
"time_to_finish": "Tid att slutföra",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "Användare",
|
||||
"user_id": "Användar-ID",
|
||||
"user_not_found": "Användare hittades inte",
|
||||
"variable": "Variabel",
|
||||
"variable_ids": "Variabel-ID:n",
|
||||
"variables": "Variabler",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "Webbplatsenkät",
|
||||
"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",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "Ändra lösenord",
|
||||
"forgot_password_email_did_not_request": "Om du inte begärde detta, vänligen ignorera detta e-postmeddelande.",
|
||||
"forgot_password_email_heading": "Ändra lösenord",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Länken är giltig i 24 timmar.",
|
||||
"forgot_password_email_link_valid_for_24_hours": "Länken är giltig i {minutes} minuter.",
|
||||
"forgot_password_email_subject": "Återställ ditt Formbricks-lösenord",
|
||||
"forgot_password_email_text": "Du har begärt en länk för att ändra ditt lösenord. Du kan göra detta genom att klicka på länken nedan:",
|
||||
"hidden_field": "Dolt fält",
|
||||
@@ -665,7 +662,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",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Lås upp Formbricks fulla kraft. Gratis i 30 dagar."
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
|
||||
"ai_data_analysis_enabled_description": "AI för att få ut mer av din data, skapa dashboards, diagram, rapporter och mer. Använder din upplevelsedata.",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "Hantera AI-drivna funktioner för den här organisationen.",
|
||||
"ai_settings_updated_successfully": "AI-inställningarna har uppdaterats",
|
||||
"ai_smart_tools_enabled": "Smarta funktioner (AI)",
|
||||
"ai_smart_tools_enabled_description": "AI som hjälper dig att göra mer på kortare tid. Rör aldrig data som samlats in med Formbricks. Används bara till t.ex. att översätta enkäter till andra språk.",
|
||||
"bulk_invite_warning_description": "På gratisplanen tilldelas alla organisationsmedlemmar alltid rollen \"Ägare\".",
|
||||
"cannot_delete_only_organization": "Detta är din enda organisation, den kan inte tas bort. Skapa en ny organisation först.",
|
||||
"cannot_leave_only_organization": "Du kan inte lämna denna organisation eftersom det är din enda organisation. Skapa en ny organisation först.",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "Hur troligt är det att du skulle rekommendera $[projectName] till en vän eller kollega?",
|
||||
"csat_question_1_lower_label": "Inte troligt",
|
||||
"csat_question_1_upper_label": "Mycket troligt",
|
||||
"csat_question_2_choice_1": "Ganska nöjd",
|
||||
"csat_question_2_choice_2": "Mycket nöjd",
|
||||
"csat_question_2_choice_1": "Mycket nöjd",
|
||||
"csat_question_2_choice_2": "Ganska nöjd",
|
||||
"csat_question_2_choice_3": "Varken nöjd eller missnöjd",
|
||||
"csat_question_2_choice_4": "Ganska missnöjd",
|
||||
"csat_question_2_choice_5": "Mycket missnöjd",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "Jag kände mig trygg när jag använde systemet.",
|
||||
"usability_rating_description": "Mät upplevd användbarhet genom att be användare betygsätta sin upplevelse med din produkt med en standardiserad 10-frågors enkät.",
|
||||
"usability_score_name": "System Usability Score (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "Tack för att du delade din arbetsflödesidé med oss! Vi håller just nu på att designa den här funktionen och din feedback hjälper oss att bygga precis det du behöver.",
|
||||
"coming_soon_title": "Vi är nästan där!",
|
||||
"follow_up_label": "Finns det något annat du vill lägga till?",
|
||||
"follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Några verktyg eller integrationer du vill ha med?",
|
||||
"generate_button": "Skapa arbetsflöde",
|
||||
"heading": "Vilket arbetsflöde vill du skapa?",
|
||||
"placeholder": "Beskriv det arbetsflöde du vill skapa…",
|
||||
"subheading": "Skapa ditt arbetsflöde på några sekunder.",
|
||||
"submit_button": "Lägg till detaljer",
|
||||
"thank_you_description": "Din input hjälper oss att bygga arbetsflödesfunktionen du faktiskt behöver. Vi håller dig uppdaterad om våra framsteg.",
|
||||
"thank_you_title": "Tack för din feedback!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"connect": "连接",
|
||||
"connect_formbricks": "连接 Formbricks",
|
||||
"connected": "已连接",
|
||||
"contact": "联系人",
|
||||
"contacts": "联系人",
|
||||
"continue": "继续",
|
||||
"copied": "已复制",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(副本 {copyNumber})",
|
||||
"e_commerce": "电子商务",
|
||||
"edit": "编辑",
|
||||
"elements": "元素",
|
||||
"email": "邮箱",
|
||||
"ending_card": "结尾卡片",
|
||||
"enter_url": "输入 URL",
|
||||
"enterprise_license": "企业 许可证",
|
||||
"environment": "环境",
|
||||
"environment_not_found": "环境 未找到",
|
||||
"environment_notice": "你 目前 位于 {environment} 环境。",
|
||||
"error": "错误",
|
||||
"error_component_description": "这个资源不存在或您没有权限访问它。",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "市场营销",
|
||||
"members": "成员",
|
||||
"members_and_teams": "成员和团队",
|
||||
"membership": "会员资格",
|
||||
"membership_not_found": "未找到会员资格",
|
||||
"meta": "元数据",
|
||||
"metadata": "元数据",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
|
||||
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "或",
|
||||
"organization": "组织",
|
||||
"organization_id": "组织 ID",
|
||||
"organization_not_found": "组织 未找到",
|
||||
"organization_settings": "组织 设置",
|
||||
"organization_teams_not_found": "未找到 组织 团队",
|
||||
"other": "其他",
|
||||
"other_filters": "其他筛选条件",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆盖层颜色",
|
||||
"overview": "概览",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "显示 响应 计数",
|
||||
"shown": "显示",
|
||||
"size": "尺寸",
|
||||
"skip": "跳过",
|
||||
"skipped": "跳过",
|
||||
"skips": "跳过",
|
||||
"some_files_failed_to_upload": "某些文件上传失败",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "调查 ID",
|
||||
"survey_languages": "调查 语言",
|
||||
"survey_live": "调查 运行中",
|
||||
"survey_not_found": "调查 未找到",
|
||||
"survey_paused": "调查 暂停。",
|
||||
"survey_type": "调查 类型",
|
||||
"surveys": "调查",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "团队 名称",
|
||||
"team_role": "团队角色",
|
||||
"teams": "团队",
|
||||
"teams_not_found": "未找到 团队",
|
||||
"terms_of_service": "服务条款",
|
||||
"text": "文本",
|
||||
"time": "时间",
|
||||
"time_to_finish": "完成 时间",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "URL",
|
||||
"user": "用户",
|
||||
"user_id": "用户 ID",
|
||||
"user_not_found": "用户 不存在",
|
||||
"variable": "变量",
|
||||
"variable_ids": "变量 ID",
|
||||
"variables": "变量",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "网站 调查",
|
||||
"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": "你 ",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "更改 密码",
|
||||
"forgot_password_email_did_not_request": "如果您 未 请求此 项 ,请 忽略 此邮件 。",
|
||||
"forgot_password_email_heading": "更改 密码",
|
||||
"forgot_password_email_link_valid_for_24_hours": "链接在 24 小时 内有效。",
|
||||
"forgot_password_email_link_valid_for_24_hours": "链接在{minutes}分钟内有效。",
|
||||
"forgot_password_email_subject": "重置您的 Formbricks 密码",
|
||||
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
|
||||
"hidden_field": "隐藏字段",
|
||||
@@ -665,7 +662,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": "创建属性",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "解锁 Formbricks 的全部功能。免费使用 30 天。"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "数据增强与分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "使用 AI 深度挖掘你的数据,设置仪表盘、图表、报告等。会处理你的体验数据。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "管理该组织的 AI 驱动功能。",
|
||||
"ai_settings_updated_successfully": "AI 设置已成功更新",
|
||||
"ai_smart_tools_enabled": "智能功能(AI)",
|
||||
"ai_smart_tools_enabled_description": "AI 帮你更高效地完成更多任务。绝不会接触 Formbricks 收集的数据,仅用于如问卷翻译等功能。",
|
||||
"bulk_invite_warning_description": "在免费计划中,所有组织成员都会被分配为 \"Owner \"角色。",
|
||||
"cannot_delete_only_organization": "这是 您 唯一的 组织,不可 删除。请 先 创建一个新的 组织。",
|
||||
"cannot_leave_only_organization": "您 不能 离开 此 组织,因为 这是 您 唯一的 组织。请 先 创建一个新的 组织。",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "您有多大可能向朋友或同事推荐这款 $[projectName] ?",
|
||||
"csat_question_1_lower_label": "不可能",
|
||||
"csat_question_1_upper_label": "非常 可能",
|
||||
"csat_question_2_choice_1": "有点 满意",
|
||||
"csat_question_2_choice_2": "非常 满意",
|
||||
"csat_question_2_choice_1": "非常 满意",
|
||||
"csat_question_2_choice_2": "有点 满意",
|
||||
"csat_question_2_choice_3": "既不 满意 也 不 不满意",
|
||||
"csat_question_2_choice_4": "有点 不满意",
|
||||
"csat_question_2_choice_5": "非常 不 满意",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "使用 系统 时 我 感到 自信。",
|
||||
"usability_rating_description": "通过要求用户使用标准化的 10 问 调查 来 评价 他们对您产品的体验,以 测量 感知 的 可用性。",
|
||||
"usability_score_name": "系统 可用性 得分 ( SUS )"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。",
|
||||
"coming_soon_title": "我们快完成啦!",
|
||||
"follow_up_label": "还有其他想补充的内容吗?",
|
||||
"follow_up_placeholder": "您希望自动化哪些具体任务?是否需要包含特定工具或集成?",
|
||||
"generate_button": "生成工作流",
|
||||
"heading": "你想创建什么样的工作流?",
|
||||
"placeholder": "请描述您希望生成的工作流程……",
|
||||
"subheading": "几秒钟生成你的工作流。",
|
||||
"submit_button": "补充细节",
|
||||
"thank_you_description": "您的反馈有助于我们打造真正适合您的工作流功能。我们会及时告知您进展。",
|
||||
"thank_you_title": "感谢你的反馈!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"connect": "連線",
|
||||
"connect_formbricks": "連線 Formbricks",
|
||||
"connected": "已連線",
|
||||
"contact": "聯絡人",
|
||||
"contacts": "聯絡人",
|
||||
"continue": "繼續",
|
||||
"copied": "已 複製",
|
||||
@@ -214,12 +215,12 @@
|
||||
"duplicate_copy_number": "(複製 {copyNumber})",
|
||||
"e_commerce": "電子商務",
|
||||
"edit": "編輯",
|
||||
"elements": "元素",
|
||||
"email": "電子郵件",
|
||||
"ending_card": "結尾卡片",
|
||||
"enter_url": "輸入 URL",
|
||||
"enterprise_license": "企業授權",
|
||||
"environment": "環境",
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
"error": "錯誤",
|
||||
"error_component_description": "此資源不存在或您沒有存取權限。",
|
||||
@@ -283,7 +284,9 @@
|
||||
"marketing": "行銷",
|
||||
"members": "成員",
|
||||
"members_and_teams": "成員與團隊",
|
||||
"membership": "會員資格",
|
||||
"membership_not_found": "找不到成員資格",
|
||||
"meta": "Meta",
|
||||
"metadata": "元數據",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
|
||||
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
@@ -323,10 +326,9 @@
|
||||
"or": "或",
|
||||
"organization": "組織",
|
||||
"organization_id": "組織 ID",
|
||||
"organization_not_found": "找不到組織",
|
||||
"organization_settings": "組織設定",
|
||||
"organization_teams_not_found": "找不到組織團隊",
|
||||
"other": "其他",
|
||||
"other_filters": "其他篩選條件",
|
||||
"others": "其他",
|
||||
"overlay_color": "覆蓋層顏色",
|
||||
"overview": "概覽",
|
||||
@@ -398,7 +400,6 @@
|
||||
"show_response_count": "顯示回應數",
|
||||
"shown": "已顯示",
|
||||
"size": "大小",
|
||||
"skip": "略過",
|
||||
"skipped": "已跳過",
|
||||
"skips": "跳過次數",
|
||||
"some_files_failed_to_upload": "部分檔案上傳失敗",
|
||||
@@ -418,7 +419,6 @@
|
||||
"survey_id": "問卷 ID",
|
||||
"survey_languages": "問卷語言",
|
||||
"survey_live": "問卷已上線",
|
||||
"survey_not_found": "找不到問卷",
|
||||
"survey_paused": "問卷已暫停。",
|
||||
"survey_type": "問卷類型",
|
||||
"surveys": "問卷",
|
||||
@@ -433,7 +433,7 @@
|
||||
"team_name": "團隊名稱",
|
||||
"team_role": "團隊角色",
|
||||
"teams": "團隊",
|
||||
"teams_not_found": "找不到團隊",
|
||||
"terms_of_service": "服務條款",
|
||||
"text": "文字",
|
||||
"time": "時間",
|
||||
"time_to_finish": "完成時間",
|
||||
@@ -457,7 +457,6 @@
|
||||
"url": "網址",
|
||||
"user": "使用者",
|
||||
"user_id": "使用者 ID",
|
||||
"user_not_found": "找不到使用者",
|
||||
"variable": "變數",
|
||||
"variable_ids": "變數 ID",
|
||||
"variables": "變數",
|
||||
@@ -472,15 +471,13 @@
|
||||
"website_survey": "網站問卷",
|
||||
"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": "您",
|
||||
@@ -509,7 +506,7 @@
|
||||
"forgot_password_email_change_password": "變更密碼",
|
||||
"forgot_password_email_did_not_request": "如果您沒有要求此操作,請忽略此電子郵件。",
|
||||
"forgot_password_email_heading": "變更密碼",
|
||||
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 24 小時。",
|
||||
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 {minutes} 分鐘。",
|
||||
"forgot_password_email_subject": "重設您的 Formbricks 密碼",
|
||||
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
|
||||
"hidden_field": "隱藏欄位",
|
||||
@@ -665,7 +662,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": "建立屬性",
|
||||
@@ -1134,6 +1130,13 @@
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "免費解鎖 Formbricks 的全部功能,為期 30 天。"
|
||||
},
|
||||
"general": {
|
||||
"ai_data_analysis_enabled": "資料增強與分析(AI)",
|
||||
"ai_data_analysis_enabled_description": "利用 AI 深入分析你的資料,建立儀表板、圖表、報告等。會處理你的體驗資料。",
|
||||
"ai_enabled": "Formbricks AI",
|
||||
"ai_enabled_description": "管理此組織的 AI 功能。",
|
||||
"ai_settings_updated_successfully": "AI 設定已成功更新",
|
||||
"ai_smart_tools_enabled": "智慧功能(AI)",
|
||||
"ai_smart_tools_enabled_description": "AI 幫你更快完成更多事。絕不會接觸 Formbricks 收集的資料,只用於像是將問卷翻譯成其他語言等用途。",
|
||||
"bulk_invite_warning_description": "在免費方案中,所有組織成員始終會被指派「擁有者」角色。",
|
||||
"cannot_delete_only_organization": "這是您唯一的組織,無法刪除。請先建立新組織。",
|
||||
"cannot_leave_only_organization": "您無法離開此組織,因為它是您唯一的組織。請先建立新組織。",
|
||||
@@ -2617,8 +2620,8 @@
|
||||
"csat_question_1_headline": "您向朋友或同事推薦此 {projectName} 的可能性有多高?",
|
||||
"csat_question_1_lower_label": "不太可能",
|
||||
"csat_question_1_upper_label": "非常可能",
|
||||
"csat_question_2_choice_1": "有點滿意",
|
||||
"csat_question_2_choice_2": "非常滿意",
|
||||
"csat_question_2_choice_1": "非常滿意",
|
||||
"csat_question_2_choice_2": "有點滿意",
|
||||
"csat_question_2_choice_3": "既不滿意也不不滿意",
|
||||
"csat_question_2_choice_4": "有點不滿意",
|
||||
"csat_question_2_choice_5": "非常不滿意",
|
||||
@@ -3340,18 +3343,5 @@
|
||||
"usability_question_9_headline": "使用 系統 時,我 感到 有 信心。",
|
||||
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
|
||||
"usability_score_name": "系統 可用性 分數 (SUS)"
|
||||
},
|
||||
"workflows": {
|
||||
"coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。",
|
||||
"coming_soon_title": "快完成囉!",
|
||||
"follow_up_label": "還有其他想補充的嗎?",
|
||||
"follow_up_placeholder": "您希望自動化哪些具體任務?有沒有想要整合的工具或功能?",
|
||||
"generate_button": "產生工作流程",
|
||||
"heading": "你想建立什麼樣的工作流程?",
|
||||
"placeholder": "請描述您想產生的工作流程……",
|
||||
"subheading": "幾秒鐘就能產生你的工作流程。",
|
||||
"submit_button": "補充細節",
|
||||
"thank_you_description": "您的意見有助於我們打造真正符合您需求的工作流程功能。我們會持續向您更新開發進度。",
|
||||
"thank_you_title": "感謝你的回饋!"
|
||||
}
|
||||
}
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -44,12 +44,22 @@ export const authenticatedApiClient = async <S extends ExtendedSchemas>({
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (err !== null && typeof err === "object" && "type" in err) {
|
||||
return handleApiError(request, err as ApiErrorResponseV2);
|
||||
return handleApiError(request, err as ApiErrorResponseV2, undefined, err);
|
||||
}
|
||||
|
||||
return handleApiError(request, {
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "error", issue: "An error occurred while processing your request." }],
|
||||
});
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "internal_server_error",
|
||||
details: [
|
||||
{
|
||||
field: "error",
|
||||
issue: "An error occurred while processing your request. Please try again later.",
|
||||
},
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
err
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { logApiRequest } from "@/modules/api/v2/lib/utils";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { handleApiError, logApiRequest } from "@/modules/api/v2/lib/utils";
|
||||
import { apiWrapper } from "../api-wrapper";
|
||||
import { authenticatedApiClient } from "../authenticated-api-client";
|
||||
|
||||
@@ -8,10 +8,15 @@ vi.mock("../api-wrapper", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
handleApiError: vi.fn(),
|
||||
logApiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authenticatedApiClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should log request and return response", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
@@ -29,4 +34,60 @@ describe("authenticatedApiClient", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(logApiRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("passes the original ApiErrorResponseV2 through to handleApiError", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
const apiError = {
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "error", issue: "boom" }],
|
||||
} as const;
|
||||
const handledResponse = new Response("error", { status: 500 });
|
||||
|
||||
vi.mocked(apiWrapper).mockRejectedValue(apiError);
|
||||
vi.mocked(handleApiError).mockReturnValue(handledResponse);
|
||||
|
||||
const handler = vi.fn();
|
||||
const response = await authenticatedApiClient({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response).toBe(handledResponse);
|
||||
expect(handleApiError).toHaveBeenCalledWith(request, apiError, undefined, apiError);
|
||||
});
|
||||
|
||||
test("passes unknown thrown errors to handleApiError as originalError", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
const thrownError = new Error("boom");
|
||||
const handledResponse = new Response("error", { status: 500 });
|
||||
|
||||
vi.mocked(apiWrapper).mockRejectedValue(thrownError);
|
||||
vi.mocked(handleApiError).mockReturnValue(handledResponse);
|
||||
|
||||
const handler = vi.fn();
|
||||
const response = await authenticatedApiClient({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response).toBe(handledResponse);
|
||||
expect(handleApiError).toHaveBeenCalledWith(
|
||||
request,
|
||||
{
|
||||
type: "internal_server_error",
|
||||
details: [
|
||||
{
|
||||
field: "error",
|
||||
issue: "An error occurred while processing your request. Please try again later.",
|
||||
},
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
thrownError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
|
||||
|
||||
const mockRequest = new Request("http://localhost");
|
||||
const mockRequest = new Request("http://localhost/api/v2/test");
|
||||
|
||||
// Add the request id header
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn((callback: (scope: any) => void) => {
|
||||
const mockScope = {
|
||||
setTag: vi.fn(),
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
};
|
||||
callback(mockScope);
|
||||
}),
|
||||
withScope: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock SENTRY_DSN constant while preserving untouched exports.
|
||||
@@ -37,6 +29,10 @@ vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
});
|
||||
|
||||
describe("utils", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("handleApiError", () => {
|
||||
test('return bad request response for "bad_request" error', async () => {
|
||||
const details = [{ field: "param", issue: "invalid" }];
|
||||
@@ -122,6 +118,57 @@ describe("utils", () => {
|
||||
expect(body.error.details).toEqual([
|
||||
{ field: "error", issue: "An error occurred while processing your request. Please try again later." },
|
||||
]);
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "API V2 error, id: 123",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "123",
|
||||
method: "GET",
|
||||
path: "/api/v2/test",
|
||||
}),
|
||||
extra: expect.objectContaining({
|
||||
error,
|
||||
originalError: error,
|
||||
}),
|
||||
contexts: expect.objectContaining({
|
||||
apiRequest: expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "123",
|
||||
method: "GET",
|
||||
path: "/api/v2/test",
|
||||
status: 500,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves originalError separately when provided", () => {
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "server", issue: "error occurred" }],
|
||||
};
|
||||
const originalError = new Error("boom");
|
||||
|
||||
handleApiError(mockRequest, error, undefined, originalError);
|
||||
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "API V2 error, id: 123",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
extra: expect.objectContaining({
|
||||
error,
|
||||
originalError: expect.objectContaining({
|
||||
message: "boom",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,13 +203,11 @@ describe("utils", () => {
|
||||
|
||||
describe("logApiRequest", () => {
|
||||
test("logs API request details", () => {
|
||||
// Mock the withContext method and its returned info method
|
||||
const infoMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
info: infoMock,
|
||||
});
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
@@ -172,23 +217,18 @@ describe("utils", () => {
|
||||
|
||||
logApiRequest(mockRequest, 200);
|
||||
|
||||
// Verify withContext was called
|
||||
expect(withContextMock).toHaveBeenCalled();
|
||||
// Verify info was called on the child logger
|
||||
expect(infoMock).toHaveBeenCalledWith("API Request Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("logs API request details without correlationId and without safe query params", () => {
|
||||
// Mock the withContext method and its returned info method
|
||||
const infoMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
info: infoMock,
|
||||
});
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
@@ -198,7 +238,6 @@ describe("utils", () => {
|
||||
|
||||
logApiRequest(mockRequest, 200);
|
||||
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
@@ -208,23 +247,19 @@ describe("utils", () => {
|
||||
})
|
||||
);
|
||||
|
||||
// Verify info was called on the child logger
|
||||
expect(infoMock).toHaveBeenCalledWith("API Request Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
});
|
||||
|
||||
describe("logApiError", () => {
|
||||
test("logs API error details with method and path", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
error: errorMock,
|
||||
});
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
@@ -238,33 +273,30 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
apiVersion: "v2",
|
||||
correlationId: "123",
|
||||
method: "POST",
|
||||
path: "/api/v2/management/surveys",
|
||||
error,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("logs API error details without correlationId", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
error: errorMock,
|
||||
});
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
const mockRequest = new Request("http://localhost/api/v2/test");
|
||||
mockRequest.headers.delete("x-request-id");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
@@ -274,44 +306,26 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
apiVersion: "v2",
|
||||
correlationId: "",
|
||||
method: "GET",
|
||||
path: "/api/test",
|
||||
path: "/api/v2/test",
|
||||
error,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
test("sends internal server errors to Sentry with direct capture context", () => {
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
error: errorMock,
|
||||
});
|
||||
|
||||
// Mock Sentry's captureException method
|
||||
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
|
||||
|
||||
// Capture the scope mock for tag verification
|
||||
const scopeSetTagMock = vi.fn();
|
||||
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
|
||||
const mockScope = {
|
||||
setTag: scopeSetTagMock,
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
};
|
||||
callback(mockScope);
|
||||
});
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
@@ -325,31 +339,49 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
apiVersion: "v2",
|
||||
correlationId: "123",
|
||||
method: "DELETE",
|
||||
path: "/api/v2/management/surveys",
|
||||
error,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Verify Sentry scope tags include method and path
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "API V2 error, id: 123",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "123",
|
||||
method: "DELETE",
|
||||
path: "/api/v2/management/surveys",
|
||||
}),
|
||||
extra: expect.objectContaining({
|
||||
error,
|
||||
originalError: error,
|
||||
}),
|
||||
contexts: expect.objectContaining({
|
||||
apiRequest: expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "123",
|
||||
method: "DELETE",
|
||||
path: "/api/v2/management/surveys",
|
||||
status: 500,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Verify Sentry.captureException was called
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("does not send to Sentry for non-internal_server_error types", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
error: errorMock,
|
||||
@@ -357,7 +389,6 @@ describe("utils", () => {
|
||||
|
||||
vi.mocked(Sentry.captureException).mockClear();
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
@@ -371,13 +402,10 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify Sentry.captureException was NOT called for non-500 errors
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
|
||||
// But structured logging should still happen
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
// Function is this file can be used in edge runtime functions, like api routes.
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
|
||||
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
const method = request.method;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
|
||||
// This is useful for tracking down issues without overloading Sentry with errors
|
||||
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
|
||||
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setTag("method", method);
|
||||
scope.setTag("path", path);
|
||||
scope.setLevel("error");
|
||||
|
||||
scope.setExtra("originalError", error);
|
||||
const err = new Error(`API V2 error, id: ${correlationId}`);
|
||||
Sentry.captureException(err);
|
||||
});
|
||||
const getStatusFromApiError = (error: ApiErrorResponseV2): number => {
|
||||
switch (error.type) {
|
||||
case "bad_request":
|
||||
return 400;
|
||||
case "unauthorized":
|
||||
return 401;
|
||||
case "forbidden":
|
||||
return 403;
|
||||
case "not_found":
|
||||
return 404;
|
||||
case "conflict":
|
||||
return 409;
|
||||
case "unprocessable_entity":
|
||||
return 422;
|
||||
case "too_many_requests":
|
||||
return 429;
|
||||
case "internal_server_error":
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
|
||||
logger
|
||||
.withContext({
|
||||
correlationId,
|
||||
method,
|
||||
path,
|
||||
error,
|
||||
})
|
||||
.error("API V2 Error Details");
|
||||
};
|
||||
|
||||
export const logApiErrorEdge = (
|
||||
request: Request,
|
||||
error: ApiErrorResponseV2,
|
||||
originalError: unknown = error
|
||||
): void => {
|
||||
reportApiError({
|
||||
request,
|
||||
status: getStatusFromApiError(error),
|
||||
error,
|
||||
originalError,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -12,9 +12,10 @@ import { logApiErrorEdge } from "./utils-edge";
|
||||
export const handleApiError = (
|
||||
request: Request,
|
||||
err: ApiErrorResponseV2,
|
||||
auditLog?: TApiAuditLog
|
||||
auditLog?: TApiAuditLog,
|
||||
originalError: unknown = err
|
||||
): Response => {
|
||||
logApiError(request, err, auditLog);
|
||||
logApiError(request, err, auditLog, originalError);
|
||||
|
||||
switch (err.type) {
|
||||
case "bad_request":
|
||||
@@ -64,9 +65,9 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
|
||||
const startTime = request.headers.get("x-start-time") || "";
|
||||
const queryParams = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
const sensitiveParams = ["apikey", "token", "secret"];
|
||||
const sensitiveParams = new Set(["apikey", "token", "secret"]);
|
||||
const safeQueryParams = Object.fromEntries(
|
||||
Object.entries(queryParams).filter(([key]) => !sensitiveParams.includes(key.toLowerCase()))
|
||||
Object.entries(queryParams).filter(([key]) => !sensitiveParams.has(key.toLowerCase()))
|
||||
);
|
||||
|
||||
logger
|
||||
@@ -74,7 +75,7 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
|
||||
method,
|
||||
path,
|
||||
responseStatus,
|
||||
duration: `${Date.now() - parseInt(startTime)} ms`,
|
||||
duration: `${Date.now() - Number.parseInt(startTime, 10)} ms`,
|
||||
correlationId,
|
||||
queryParams: safeQueryParams,
|
||||
})
|
||||
@@ -83,8 +84,13 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
|
||||
logAuditLog(request, auditLog);
|
||||
};
|
||||
|
||||
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => {
|
||||
logApiErrorEdge(request, error);
|
||||
export const logApiError = (
|
||||
request: Request,
|
||||
error: ApiErrorResponseV2,
|
||||
auditLog?: TApiAuditLog,
|
||||
originalError: unknown = error
|
||||
): void => {
|
||||
logApiErrorEdge(request, error, originalError);
|
||||
|
||||
logAuditLog(request, auditLog);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
// Import mocked functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { sendForgotPasswordEmail } from "@/modules/email";
|
||||
import { forgotPasswordAction } from "./actions";
|
||||
|
||||
// Mock dependencies
|
||||
@@ -27,8 +28,14 @@ vi.mock("@/modules/auth/lib/user", () => ({
|
||||
getUserByEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendForgotPasswordEmail: vi.fn(),
|
||||
vi.mock("@/modules/auth/forgot-password/lib/password-reset-service", () => ({
|
||||
requestPasswordReset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client", () => ({
|
||||
@@ -77,7 +84,7 @@ describe("forgotPasswordAction", () => {
|
||||
);
|
||||
|
||||
expect(getUserByEmail).not.toHaveBeenCalled();
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
expect(requestPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should use correct rate limit configuration", async () => {
|
||||
@@ -104,39 +111,39 @@ describe("forgotPasswordAction", () => {
|
||||
|
||||
describe("Password Reset Flow", () => {
|
||||
test("should send password reset email when user exists with email identity provider", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendForgotPasswordEmail).toHaveBeenCalledWith(mockUser);
|
||||
expect(requestPasswordReset).toHaveBeenCalledWith(mockUser, "public");
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should not send email when user doesn't exist", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
expect(requestPasswordReset).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should not send email when user has non-email identity provider", async () => {
|
||||
const ssoUser = { ...mockUser, identityProvider: "google" };
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
expect(requestPasswordReset).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -146,7 +153,7 @@ describe("forgotPasswordAction", () => {
|
||||
// This test verifies that password reset is enabled by default
|
||||
// The actual PASSWORD_RESET_DISABLED check is part of the implementation
|
||||
// and we've mocked it as false, so rate limiting should work normally
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
@@ -168,7 +175,7 @@ describe("forgotPasswordAction", () => {
|
||||
});
|
||||
|
||||
test("should handle user lookup errors after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
|
||||
@@ -178,23 +185,30 @@ describe("forgotPasswordAction", () => {
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle email sending errors after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
test("should propagate unexpected password reset request errors after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(sendForgotPasswordEmail).mockRejectedValue(new Error("Email service error"));
|
||||
vi.mocked(requestPasswordReset).mockRejectedValue(new Error("Password reset request failed"));
|
||||
|
||||
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
|
||||
"Email service error"
|
||||
);
|
||||
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stage: "dispatch",
|
||||
userId: mockUser.id,
|
||||
}),
|
||||
"Password reset request failed"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Considerations", () => {
|
||||
test("should always return success even for non-existent users to prevent email enumeration", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
@@ -204,17 +218,17 @@ describe("forgotPasswordAction", () => {
|
||||
|
||||
test("should always return success even for SSO users to prevent identity provider enumeration", async () => {
|
||||
const ssoUser = { ...mockUser, identityProvider: "github" };
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
expect(requestPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should rate limit all requests regardless of user existence", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
|
||||
// Test with existing user
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZUserEmail } from "@formbricks/types/user";
|
||||
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { sendForgotPasswordEmail } from "@/modules/email";
|
||||
|
||||
const ZForgotPasswordAction = z.object({
|
||||
email: ZUserEmail,
|
||||
@@ -26,7 +27,11 @@ export const forgotPasswordAction = actionClient
|
||||
const user = await getUserByEmail(parsedInput.email);
|
||||
|
||||
if (user && user.identityProvider === "email") {
|
||||
await sendForgotPasswordEmail(user);
|
||||
try {
|
||||
await requestPasswordReset(user, "public");
|
||||
} catch (error) {
|
||||
logger.error({ error, stage: "dispatch", userId: user.id }, "Password reset request failed");
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import {
|
||||
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
InvalidPasswordResetTokenError,
|
||||
} from "@formbricks/types/errors";
|
||||
import type { TUser } from "@formbricks/types/user";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { deleteSessionsByUserId } from "@/modules/auth/lib/auth-session-repository";
|
||||
import { sendPasswordResetLinkEmail, sendPasswordResetNotifyEmail } from "@/modules/email";
|
||||
import {
|
||||
ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE,
|
||||
completePasswordReset,
|
||||
getPasswordResetTokenLifetimeInMinutes,
|
||||
requestPasswordReset,
|
||||
} from "./password-reset-service";
|
||||
import type { TPasswordResetTokenRecord } from "./password-reset-token-repository";
|
||||
|
||||
type TPasswordResetTestUser = Pick<TUser, "id" | "email" | "locale" | "emailVerified"> & {
|
||||
password: string;
|
||||
};
|
||||
|
||||
type TPasswordResetAuditUserFixture = Pick<
|
||||
TPasswordResetTestUser,
|
||||
"id" | "email" | "locale" | "emailVerified"
|
||||
>;
|
||||
|
||||
type TPasswordResetSessionRecord = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
sessionToken: string;
|
||||
userId: string;
|
||||
expires: Date;
|
||||
};
|
||||
type TPasswordResetTransactionStub = {
|
||||
user: {
|
||||
findUnique: (args: { where: { id: string } }) => Promise<TPasswordResetAuditUserFixture | null>;
|
||||
update: (args: {
|
||||
where: { id: string };
|
||||
data: { password: string };
|
||||
}) => Promise<TPasswordResetAuditUserFixture>;
|
||||
};
|
||||
};
|
||||
|
||||
const testState = vi.hoisted(() => {
|
||||
const tokenStore = new Map<string, TPasswordResetTokenRecord>();
|
||||
const users = new Map<string, TPasswordResetTestUser>();
|
||||
const sessionStore = new Map<string, TPasswordResetSessionRecord>();
|
||||
|
||||
const cloneTokenRecord = (record: TPasswordResetTokenRecord): TPasswordResetTokenRecord => ({
|
||||
...record,
|
||||
expiresAt: new Date(record.expiresAt),
|
||||
createdAt: new Date(record.createdAt),
|
||||
updatedAt: new Date(record.updatedAt),
|
||||
});
|
||||
|
||||
const cloneUser = (user: TPasswordResetTestUser): TPasswordResetTestUser => ({
|
||||
...user,
|
||||
emailVerified: user.emailVerified ? new Date(user.emailVerified) : null,
|
||||
});
|
||||
|
||||
const cloneSessionRecord = (session: TPasswordResetSessionRecord): TPasswordResetSessionRecord => ({
|
||||
...session,
|
||||
createdAt: new Date(session.createdAt),
|
||||
updatedAt: new Date(session.updatedAt),
|
||||
expires: new Date(session.expires),
|
||||
});
|
||||
|
||||
const selectAuditUser = (user: TPasswordResetTestUser): TPasswordResetAuditUserFixture => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
locale: user.locale,
|
||||
emailVerified: user.emailVerified,
|
||||
});
|
||||
|
||||
const mockUpsertActiveToken = vi.fn(async (userId: string, tokenHash: string, expiresAt: Date) => {
|
||||
const existingRecord = tokenStore.get(userId);
|
||||
const now = new Date();
|
||||
const record = {
|
||||
id: existingRecord?.id ?? `prt_${userId}`,
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
createdAt: existingRecord?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
tokenStore.set(userId, record);
|
||||
return record;
|
||||
});
|
||||
|
||||
const mockFindByTokenHash = vi.fn(async (tokenHash: string) => {
|
||||
return [...tokenStore.values()].find((record) => record.tokenHash === tokenHash) ?? null;
|
||||
});
|
||||
|
||||
const mockDeleteByTokenHash = vi.fn(async (tokenHash: string) => {
|
||||
const existingRecord = [...tokenStore.values()].find((record) => record.tokenHash === tokenHash);
|
||||
|
||||
if (!existingRecord) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
tokenStore.delete(existingRecord.userId);
|
||||
return 1;
|
||||
});
|
||||
|
||||
const mockConsumeActiveToken = vi.fn(async (tokenHash: string, now: Date) => {
|
||||
const record = [...tokenStore.values()].find(
|
||||
(storedRecord) => storedRecord.tokenHash === tokenHash && storedRecord.expiresAt > now
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
tokenStore.delete(record.userId);
|
||||
return 1;
|
||||
});
|
||||
|
||||
const mockDeleteSessionsByUserId = vi.fn(async (userId: string) => {
|
||||
const matchingSessionEntries = [...sessionStore.entries()].filter(
|
||||
([, sessionRecord]) => sessionRecord.userId === userId
|
||||
);
|
||||
|
||||
for (const [sessionId] of matchingSessionEntries) {
|
||||
sessionStore.delete(sessionId);
|
||||
}
|
||||
|
||||
return matchingSessionEntries.length;
|
||||
});
|
||||
|
||||
const restoreMap = <TKey, TValue>(
|
||||
store: Map<TKey, TValue>,
|
||||
snapshot: ReadonlyArray<readonly [TKey, TValue]>
|
||||
): void => {
|
||||
store.clear();
|
||||
snapshot.forEach(([key, value]) => store.set(key, value));
|
||||
};
|
||||
const mockTransaction = vi.fn(async <T>(callback: (tx: TPasswordResetTransactionStub) => Promise<T>) => {
|
||||
const tokenSnapshot = [...tokenStore.entries()].map(
|
||||
([key, record]) => [key, cloneTokenRecord(record)] as const
|
||||
);
|
||||
const userSnapshot = [...users.entries()].map(([key, user]) => [key, cloneUser(user)] as const);
|
||||
const sessionSnapshot = [...sessionStore.entries()].map(
|
||||
([key, session]) => [key, cloneSessionRecord(session)] as const
|
||||
);
|
||||
const tx: TPasswordResetTransactionStub = {
|
||||
user: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
const user = users.get(where.id);
|
||||
return user ? selectAuditUser(user) : null;
|
||||
}),
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
const user = users.get(where.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const updatedUser = {
|
||||
...user,
|
||||
password: data.password,
|
||||
};
|
||||
|
||||
users.set(where.id, updatedUser);
|
||||
return selectAuditUser(updatedUser);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
return await callback(tx);
|
||||
} catch (error) {
|
||||
restoreMap(tokenStore, tokenSnapshot);
|
||||
restoreMap(users, userSnapshot);
|
||||
restoreMap(sessionStore, sessionSnapshot);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
tokenStore,
|
||||
users,
|
||||
sessionStore,
|
||||
mockUpsertActiveToken,
|
||||
mockFindByTokenHash,
|
||||
mockDeleteByTokenHash,
|
||||
mockDeleteSessionsByUserId,
|
||||
mockConsumeActiveToken,
|
||||
mockTransaction,
|
||||
};
|
||||
});
|
||||
|
||||
const constantsState = vi.hoisted(() => ({
|
||||
debugShowResetLink: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/hash-string", () => ({
|
||||
hashString: vi.fn((value: string) => `hash:${value}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
get DEBUG_SHOW_RESET_LINK() {
|
||||
return constantsState.debugShowResetLink;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
hashPassword: vi.fn(async (password: string) => `hashed:${password}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendPasswordResetLinkEmail: vi.fn(async () => true),
|
||||
sendPasswordResetNotifyEmail: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$transaction: testState.mockTransaction,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./password-reset-token-repository", () => ({
|
||||
upsertActiveToken: testState.mockUpsertActiveToken,
|
||||
findByTokenHash: testState.mockFindByTokenHash,
|
||||
deleteByTokenHash: testState.mockDeleteByTokenHash,
|
||||
consumeActiveToken: testState.mockConsumeActiveToken,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/auth-session-repository", () => ({
|
||||
deleteSessionsByUserId: testState.mockDeleteSessionsByUserId,
|
||||
}));
|
||||
describe("password-reset-service", () => {
|
||||
const user = {
|
||||
id: "cm8z6bn2q000008l34h8g7k9m",
|
||||
email: "user@example.com",
|
||||
locale: "en-US" as const,
|
||||
};
|
||||
|
||||
const parseTokenFromResetLink = (): string => {
|
||||
const lastCall = vi.mocked(sendPasswordResetLinkEmail).mock.calls.at(-1);
|
||||
const verifyLink = lastCall?.[0]?.verifyLink;
|
||||
|
||||
if (!verifyLink) {
|
||||
throw new Error("No verify link found");
|
||||
}
|
||||
|
||||
const url = new URL(verifyLink);
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No token found in verify link");
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
const parseTokenFromDebugLog = (): string => {
|
||||
const verifyLink = vi
|
||||
.mocked(logger.info)
|
||||
.mock.calls.map(([payload]) => payload?.verifyLink)
|
||||
.find((loggedVerifyLink): loggedVerifyLink is string => typeof loggedVerifyLink === "string");
|
||||
|
||||
if (!verifyLink) {
|
||||
throw new Error("No debug verify link found");
|
||||
}
|
||||
|
||||
const url = new URL(verifyLink);
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No token found in debug verify link");
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
const getStoredToken = (userId: string): TPasswordResetTokenRecord => {
|
||||
const storedToken = testState.tokenStore.get(userId);
|
||||
|
||||
if (!storedToken) {
|
||||
throw new Error("No stored token found");
|
||||
}
|
||||
|
||||
return storedToken;
|
||||
};
|
||||
|
||||
const getStoredUser = (userId: string): TPasswordResetTestUser => {
|
||||
const storedUser = testState.users.get(userId);
|
||||
|
||||
if (!storedUser) {
|
||||
throw new Error("No stored user found");
|
||||
}
|
||||
|
||||
return storedUser;
|
||||
};
|
||||
|
||||
const createSessionRecord = (userId: string, sessionId: string): TPasswordResetSessionRecord => {
|
||||
const now = new Date("2026-03-30T12:00:00.000Z");
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
userId,
|
||||
sessionToken: `session-token-${sessionId}`,
|
||||
expires: new Date("2026-03-31T12:00:00.000Z"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
};
|
||||
|
||||
const getSessionsForUser = (userId: string): TPasswordResetSessionRecord[] =>
|
||||
[...testState.sessionStore.values()].filter((session) => session.userId === userId);
|
||||
beforeEach(() => {
|
||||
constantsState.debugShowResetLink = false;
|
||||
testState.tokenStore.clear();
|
||||
testState.users.clear();
|
||||
testState.sessionStore.clear();
|
||||
testState.users.set(user.id, {
|
||||
...user,
|
||||
emailVerified: null,
|
||||
password: "old-password-hash",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("issues a hashed token with the configured default lifetime", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-30T12:00:00.000Z"));
|
||||
|
||||
await requestPasswordReset(user, "public");
|
||||
|
||||
const rawToken = parseTokenFromResetLink();
|
||||
const storedToken = getStoredToken(user.id);
|
||||
|
||||
expect(getPasswordResetTokenLifetimeInMinutes()).toBe(30);
|
||||
expect(storedToken.tokenHash).toBe(`hash:${rawToken}`);
|
||||
expect(storedToken.tokenHash).not.toBe(rawToken);
|
||||
expect(storedToken.expiresAt).toEqual(new Date("2026-03-30T12:30:00.000Z"));
|
||||
expect(sendPasswordResetLinkEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: user.email,
|
||||
locale: user.locale,
|
||||
linkValidityInMinutes: 30,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("invalidates the previous token when a new reset request is issued", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const firstToken = parseTokenFromResetLink();
|
||||
|
||||
await requestPasswordReset(user, "public");
|
||||
const secondToken = parseTokenFromResetLink();
|
||||
|
||||
await expect(completePasswordReset(firstToken, "Password123")).rejects.toMatchObject({
|
||||
name: "InvalidPasswordResetTokenError",
|
||||
message: INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
});
|
||||
|
||||
const result = await completePasswordReset(secondToken, "Password123");
|
||||
|
||||
expect(result.userId).toBe(user.id);
|
||||
expect(getStoredUser(user.id).password).toBe("hashed:Password123");
|
||||
});
|
||||
|
||||
test("rejects expired reset tokens", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
|
||||
testState.tokenStore.set(user.id, {
|
||||
...getStoredToken(user.id),
|
||||
expiresAt: new Date(Date.now() - 60 * 1000),
|
||||
});
|
||||
|
||||
await expect(completePasswordReset(token, "Password123")).rejects.toMatchObject({
|
||||
name: "InvalidPasswordResetTokenError",
|
||||
reason: "expired",
|
||||
message: INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stage: "consume",
|
||||
reason: "expired",
|
||||
userId: user.id,
|
||||
}),
|
||||
"Rejected password reset token"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects unknown and legacy jwt reset tokens", async () => {
|
||||
await expect(completePasswordReset("unknown-token", "Password123")).rejects.toBeInstanceOf(
|
||||
InvalidPasswordResetTokenError
|
||||
);
|
||||
await expect(completePasswordReset("legacy.jwt.token", "Password123")).rejects.toMatchObject({
|
||||
reason: "legacy_jwt",
|
||||
message: INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
});
|
||||
});
|
||||
|
||||
test("consumes a token only once", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
|
||||
await expect(completePasswordReset(token, "Password123")).resolves.toMatchObject({
|
||||
userId: user.id,
|
||||
});
|
||||
await expect(completePasswordReset(token, "Password123")).rejects.toBeInstanceOf(
|
||||
InvalidPasswordResetTokenError
|
||||
);
|
||||
});
|
||||
|
||||
test("revokes all active sessions for the user after a successful reset", async () => {
|
||||
const otherUserId = "cm8z6bn2q000008l34h8g7k9n";
|
||||
testState.sessionStore.set("session-user-1", createSessionRecord(user.id, "session-user-1"));
|
||||
testState.sessionStore.set("session-user-2", createSessionRecord(user.id, "session-user-2"));
|
||||
testState.sessionStore.set("session-other-1", createSessionRecord(otherUserId, "session-other-1"));
|
||||
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
|
||||
await completePasswordReset(token, "Password123");
|
||||
|
||||
expect(deleteSessionsByUserId).toHaveBeenCalledWith(user.id, expect.any(Object));
|
||||
expect(getSessionsForUser(user.id)).toHaveLength(0);
|
||||
expect(getSessionsForUser(otherUserId)).toHaveLength(1);
|
||||
});
|
||||
test("allows only one successful result for concurrent token submissions", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
completePasswordReset(token, "Password123"),
|
||||
completePasswordReset(token, "Password123"),
|
||||
]);
|
||||
|
||||
const fulfilledResults = results.filter((result) => result.status === "fulfilled");
|
||||
const rejectedResults = results.filter((result) => result.status === "rejected");
|
||||
|
||||
expect(fulfilledResults).toHaveLength(1);
|
||||
expect(rejectedResults).toHaveLength(1);
|
||||
expect((rejectedResults[0] as PromiseRejectedResult).reason).toBeInstanceOf(
|
||||
InvalidPasswordResetTokenError
|
||||
);
|
||||
});
|
||||
|
||||
test("revokes the issued token when email delivery fails for a public request", async () => {
|
||||
vi.mocked(sendPasswordResetLinkEmail).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(requestPasswordReset(user, "public")).resolves.toBeUndefined();
|
||||
|
||||
const revokedToken = parseTokenFromResetLink();
|
||||
|
||||
expect(testState.tokenStore.size).toBe(0);
|
||||
expect(testState.mockDeleteByTokenHash).toHaveBeenCalledWith(`hash:${revokedToken}`);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "public",
|
||||
stage: "send",
|
||||
userId: user.id,
|
||||
}),
|
||||
"Password reset request failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("logs the reset link instead of sending an email when DEBUG_SHOW_RESET_LINK is enabled", async () => {
|
||||
constantsState.debugShowResetLink = true;
|
||||
|
||||
await requestPasswordReset(user, "public");
|
||||
|
||||
expect(sendPasswordResetLinkEmail).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
verifyLink: expect.stringMatching(/^http:\/\/localhost:3000\/auth\/forgot-password\/reset\?token=/),
|
||||
}),
|
||||
"DEBUG_SHOW_RESET_LINK is enabled; password reset email delivery skipped"
|
||||
);
|
||||
});
|
||||
|
||||
test("logs and suppresses token issuance failures for public requests", async () => {
|
||||
testState.mockUpsertActiveToken.mockRejectedValueOnce(new Error("Database unavailable"));
|
||||
|
||||
await expect(requestPasswordReset(user, "public")).resolves.toBeUndefined();
|
||||
|
||||
expect(sendPasswordResetLinkEmail).not.toHaveBeenCalled();
|
||||
expect(testState.mockDeleteByTokenHash).not.toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "public",
|
||||
stage: "issue",
|
||||
userId: user.id,
|
||||
}),
|
||||
"Password reset request failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("surfaces profile reset request failures after revoking the token", async () => {
|
||||
vi.mocked(sendPasswordResetLinkEmail).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(requestPasswordReset(user, "profile")).rejects.toThrow(
|
||||
ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE
|
||||
);
|
||||
expect(testState.tokenStore.size).toBe(0);
|
||||
});
|
||||
|
||||
test("does not roll back a successful password reset when the notification email fails", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
testState.sessionStore.set("session-user-1", createSessionRecord(user.id, "session-user-1"));
|
||||
vi.mocked(sendPasswordResetNotifyEmail).mockResolvedValueOnce(false);
|
||||
|
||||
const result = await completePasswordReset(token, "Password123");
|
||||
|
||||
expect(result.userId).toBe(user.id);
|
||||
expect(getStoredUser(user.id).password).toBe("hashed:Password123");
|
||||
expect(getSessionsForUser(user.id)).toHaveLength(0);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stage: "notify_email",
|
||||
userId: user.id,
|
||||
}),
|
||||
"Failed to send password reset notification email"
|
||||
);
|
||||
});
|
||||
|
||||
test("skips notification email delivery when DEBUG_SHOW_RESET_LINK is enabled", async () => {
|
||||
constantsState.debugShowResetLink = true;
|
||||
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromDebugLog();
|
||||
|
||||
await completePasswordReset(token, "Password123");
|
||||
|
||||
expect(sendPasswordResetNotifyEmail).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: user.id,
|
||||
}),
|
||||
"DEBUG_SHOW_RESET_LINK is enabled; password reset notification delivery skipped"
|
||||
);
|
||||
});
|
||||
|
||||
test("validates the reset token before hashing the new password", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
|
||||
await completePasswordReset(token, "Password123");
|
||||
|
||||
expect(testState.mockFindByTokenHash).toHaveBeenCalledBefore(vi.mocked(hashPassword));
|
||||
expect(vi.mocked(hashPassword)).toHaveBeenCalledBefore(vi.mocked(prisma.$transaction));
|
||||
expect(hashString).toHaveBeenCalledWith(token);
|
||||
});
|
||||
|
||||
test("rejects invalid reset tokens before hashing the new password", async () => {
|
||||
await expect(completePasswordReset("unknown-token", "Password123")).rejects.toBeInstanceOf(
|
||||
InvalidPasswordResetTokenError
|
||||
);
|
||||
|
||||
expect(hashPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rolls back the reset when session revocation fails", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
testState.sessionStore.set("session-user-1", createSessionRecord(user.id, "session-user-1"));
|
||||
testState.mockDeleteSessionsByUserId.mockRejectedValueOnce(new Error("Session revoke failed"));
|
||||
|
||||
await expect(completePasswordReset(token, "Password123")).rejects.toThrow("Session revoke failed");
|
||||
|
||||
expect(getStoredUser(user.id).password).toBe("old-password-hash");
|
||||
expect(getStoredToken(user.id).tokenHash).toBe(`hash:${token}`);
|
||||
expect(getSessionsForUser(user.id)).toHaveLength(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stage: "session_revoke",
|
||||
userId: user.id,
|
||||
}),
|
||||
"Password reset completion failed"
|
||||
);
|
||||
|
||||
await expect(completePasswordReset(token, "Password123")).resolves.toMatchObject({
|
||||
userId: user.id,
|
||||
});
|
||||
expect(getStoredUser(user.id).password).toBe("hashed:Password123");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import crypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
|
||||
InvalidPasswordResetTokenError,
|
||||
} from "@formbricks/types/errors";
|
||||
import type { TUserEmail, TUserLocale } from "@formbricks/types/user";
|
||||
import { ZUserEmail, ZUserLocale, ZUserPassword } from "@formbricks/types/user";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { DEBUG_SHOW_RESET_LINK, PASSWORD_RESET_TOKEN_LIFETIME_MINUTES, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { deleteSessionsByUserId } from "@/modules/auth/lib/auth-session-repository";
|
||||
import { sendPasswordResetLinkEmail, sendPasswordResetNotifyEmail } from "@/modules/email";
|
||||
import {
|
||||
consumeActiveToken,
|
||||
deleteByTokenHash,
|
||||
findByTokenHash,
|
||||
upsertActiveToken,
|
||||
} from "./password-reset-token-repository";
|
||||
|
||||
export const ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE = "ERR_RECOVERY_RESET_LINK_EMAIL_FAILED";
|
||||
export const ACCOUNT_RECOVERY_NOTIFICATION_EMAIL_ERROR_CODE = "ERR_RECOVERY_RESET_NOTIFICATION_EMAIL_FAILED";
|
||||
|
||||
const ZPasswordResetSource = z.enum(["public", "profile"]);
|
||||
|
||||
const passwordResetAuditSelection = {
|
||||
id: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
emailVerified: true,
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
type TPasswordResetRequestSource = z.infer<typeof ZPasswordResetSource>;
|
||||
|
||||
type TPasswordResetRecipient = {
|
||||
id: string;
|
||||
email: TUserEmail;
|
||||
locale: TUserLocale;
|
||||
};
|
||||
|
||||
type TPasswordResetAuditUser = Prisma.UserGetPayload<{
|
||||
select: typeof passwordResetAuditSelection;
|
||||
}>;
|
||||
|
||||
class PasswordResetLinkEmailError extends Error {
|
||||
code = ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE;
|
||||
|
||||
constructor() {
|
||||
super(ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE);
|
||||
this.name = "PasswordResetLinkEmailError";
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordResetNotificationEmailError extends Error {
|
||||
code = ACCOUNT_RECOVERY_NOTIFICATION_EMAIL_ERROR_CODE;
|
||||
|
||||
constructor() {
|
||||
super(ACCOUNT_RECOVERY_NOTIFICATION_EMAIL_ERROR_CODE);
|
||||
this.name = "PasswordResetNotificationEmailError";
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordResetSessionRevocationError extends Error {
|
||||
userId: string;
|
||||
cause: unknown;
|
||||
|
||||
constructor(userId: string, cause: unknown) {
|
||||
super("ERR_ACCOUNT_RECOVERY_SESSION_REVOKE_FAILED");
|
||||
this.name = "PasswordResetSessionRevocationError";
|
||||
this.userId = userId;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
export const getPasswordResetTokenLifetimeInMinutes = (): number => PASSWORD_RESET_TOKEN_LIFETIME_MINUTES;
|
||||
|
||||
const buildPasswordResetLink = (token: string): string =>
|
||||
`${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const isLegacyPasswordResetToken = (token: string): boolean => token.split(".").length === 3;
|
||||
|
||||
const logPasswordResetRequestFailure = ({
|
||||
error,
|
||||
source,
|
||||
stage,
|
||||
userId,
|
||||
}: {
|
||||
error: unknown;
|
||||
source: TPasswordResetRequestSource;
|
||||
stage: "issue" | "send" | "revoke";
|
||||
userId: string;
|
||||
}) => {
|
||||
logger.error({ error, source, stage, userId }, "Password reset request failed");
|
||||
};
|
||||
|
||||
const logPasswordResetTokenRejection = (error: InvalidPasswordResetTokenError) => {
|
||||
logger.warn(
|
||||
{
|
||||
stage: "consume",
|
||||
reason: error.reason ?? "invalid_or_superseded",
|
||||
userId: error.userId,
|
||||
},
|
||||
"Rejected password reset token"
|
||||
);
|
||||
};
|
||||
|
||||
const createInvalidPasswordResetTokenError = (
|
||||
reason: string,
|
||||
userId?: string
|
||||
): InvalidPasswordResetTokenError =>
|
||||
new InvalidPasswordResetTokenError(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE, reason, userId);
|
||||
|
||||
const getPasswordResetExpiry = (): Date =>
|
||||
new Date(Date.now() + getPasswordResetTokenLifetimeInMinutes() * 60 * 1000);
|
||||
|
||||
const assertEmailWasSent = (didSendEmail: boolean, error: Error): void => {
|
||||
if (!didSendEmail) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const revokeIssuedPasswordResetToken = async (
|
||||
userId: string,
|
||||
tokenHash: string,
|
||||
source: TPasswordResetRequestSource
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await deleteByTokenHash(tokenHash);
|
||||
} catch (error) {
|
||||
logPasswordResetRequestFailure({
|
||||
error,
|
||||
source,
|
||||
stage: "revoke",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sendPasswordResetLink = async (user: TPasswordResetRecipient, verifyLink: string): Promise<void> => {
|
||||
if (DEBUG_SHOW_RESET_LINK) {
|
||||
logger.info({ verifyLink }, "DEBUG_SHOW_RESET_LINK is enabled; password reset email delivery skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
const didSendEmail = await sendPasswordResetLinkEmail({
|
||||
email: user.email,
|
||||
locale: user.locale,
|
||||
verifyLink,
|
||||
linkValidityInMinutes: getPasswordResetTokenLifetimeInMinutes(),
|
||||
});
|
||||
|
||||
assertEmailWasSent(didSendEmail, new PasswordResetLinkEmailError());
|
||||
};
|
||||
|
||||
const updatePasswordWithActiveResetToken = async (
|
||||
tokenHash: string,
|
||||
hashedPassword: string,
|
||||
now: Date
|
||||
): Promise<{
|
||||
userId: string;
|
||||
oldUser: TPasswordResetAuditUser;
|
||||
updatedUser: TPasswordResetAuditUser;
|
||||
}> =>
|
||||
prisma.$transaction(async (tx) => {
|
||||
const tokenRecord = await findByTokenHash(tokenHash, tx);
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw createInvalidPasswordResetTokenError("invalid_or_superseded");
|
||||
}
|
||||
|
||||
if (tokenRecord.expiresAt <= now) {
|
||||
throw createInvalidPasswordResetTokenError("expired", tokenRecord.userId);
|
||||
}
|
||||
|
||||
const oldUser = await tx.user.findUnique({
|
||||
where: {
|
||||
id: tokenRecord.userId,
|
||||
},
|
||||
select: passwordResetAuditSelection,
|
||||
});
|
||||
|
||||
if (!oldUser) {
|
||||
throw createInvalidPasswordResetTokenError("invalid_or_superseded", tokenRecord.userId);
|
||||
}
|
||||
|
||||
const consumedTokenCount = await consumeActiveToken(tokenHash, now, tx);
|
||||
|
||||
if (consumedTokenCount !== 1) {
|
||||
throw createInvalidPasswordResetTokenError("replay", tokenRecord.userId);
|
||||
}
|
||||
|
||||
const updatedUser = await tx.user.update({
|
||||
where: {
|
||||
id: tokenRecord.userId,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
select: passwordResetAuditSelection,
|
||||
});
|
||||
|
||||
try {
|
||||
await deleteSessionsByUserId(tokenRecord.userId, tx);
|
||||
} catch (error) {
|
||||
throw new PasswordResetSessionRevocationError(tokenRecord.userId, error);
|
||||
}
|
||||
return {
|
||||
userId: tokenRecord.userId,
|
||||
oldUser,
|
||||
updatedUser,
|
||||
};
|
||||
});
|
||||
|
||||
const assertResetTokenCanStillBeUsed = async (tokenHash: string, now: Date): Promise<void> => {
|
||||
const tokenRecord = await findByTokenHash(tokenHash);
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw createInvalidPasswordResetTokenError("invalid_or_superseded");
|
||||
}
|
||||
|
||||
if (tokenRecord.expiresAt <= now) {
|
||||
throw createInvalidPasswordResetTokenError("expired", tokenRecord.userId);
|
||||
}
|
||||
};
|
||||
|
||||
const sendPasswordResetNotification = async ({
|
||||
userId,
|
||||
email,
|
||||
locale,
|
||||
}: {
|
||||
userId: string;
|
||||
email: string;
|
||||
locale: TUserLocale;
|
||||
}): Promise<void> => {
|
||||
if (DEBUG_SHOW_RESET_LINK) {
|
||||
logger.info({ userId }, "DEBUG_SHOW_RESET_LINK is enabled; password reset notification delivery skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const didSendNotificationEmail = await sendPasswordResetNotifyEmail({
|
||||
email,
|
||||
locale,
|
||||
});
|
||||
|
||||
assertEmailWasSent(didSendNotificationEmail, new PasswordResetNotificationEmailError());
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
error,
|
||||
stage: "notify_email",
|
||||
userId,
|
||||
},
|
||||
"Failed to send password reset notification email"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const requestPasswordReset = async (
|
||||
user: TPasswordResetRecipient,
|
||||
source: TPasswordResetRequestSource
|
||||
): Promise<void> => {
|
||||
validateInputs(
|
||||
[user.id, ZId],
|
||||
[user.email, ZUserEmail],
|
||||
[user.locale, ZUserLocale],
|
||||
[source, ZPasswordResetSource]
|
||||
);
|
||||
|
||||
const rawToken = crypto.randomBytes(32).toString("base64url");
|
||||
const tokenHash = hashString(rawToken);
|
||||
const expiresAt = getPasswordResetExpiry();
|
||||
const verifyLink = buildPasswordResetLink(rawToken);
|
||||
let tokenIssued = false;
|
||||
|
||||
try {
|
||||
await upsertActiveToken(user.id, tokenHash, expiresAt);
|
||||
tokenIssued = true;
|
||||
await sendPasswordResetLink(user, verifyLink);
|
||||
} catch (error) {
|
||||
logPasswordResetRequestFailure({
|
||||
error,
|
||||
source,
|
||||
stage: tokenIssued ? "send" : "issue",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (tokenIssued) {
|
||||
await revokeIssuedPasswordResetToken(user.id, tokenHash, source);
|
||||
}
|
||||
|
||||
if (source === "profile") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const completePasswordReset = async (
|
||||
rawToken: string,
|
||||
password: string
|
||||
): Promise<{
|
||||
userId: string;
|
||||
oldUser: TPasswordResetAuditUser;
|
||||
updatedUser: TPasswordResetAuditUser;
|
||||
}> => {
|
||||
validateInputs([rawToken, z.string().min(1)], [password, ZUserPassword]);
|
||||
|
||||
if (isLegacyPasswordResetToken(rawToken)) {
|
||||
const error = createInvalidPasswordResetTokenError("legacy_jwt");
|
||||
logPasswordResetTokenRejection(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const tokenHash = hashString(rawToken);
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await assertResetTokenCanStillBeUsed(tokenHash, now);
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const result = await updatePasswordWithActiveResetToken(tokenHash, hashedPassword, now);
|
||||
await sendPasswordResetNotification({
|
||||
userId: result.userId,
|
||||
email: result.updatedUser.email,
|
||||
locale: result.updatedUser.locale,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidPasswordResetTokenError) {
|
||||
logPasswordResetTokenRejection(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof PasswordResetSessionRevocationError) {
|
||||
logger.error(
|
||||
{
|
||||
error: error.cause instanceof Error ? error.cause : error,
|
||||
stage: "session_revoke",
|
||||
userId: error.userId,
|
||||
},
|
||||
"Password reset completion failed"
|
||||
);
|
||||
throw error.cause instanceof Error ? error.cause : error;
|
||||
}
|
||||
logger.error({ error, stage: "password_update" }, "Password reset completion failed");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import {
|
||||
consumeActiveToken,
|
||||
deleteByTokenHash,
|
||||
findByTokenHash,
|
||||
upsertActiveToken,
|
||||
} from "./password-reset-token-repository";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
passwordResetToken: {
|
||||
upsert: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("password-reset-token-repository", () => {
|
||||
const userId = "cm8z6bn2q000008l34h8g7k9m";
|
||||
const mockTokenRecord = {
|
||||
id: "prt_123",
|
||||
userId,
|
||||
tokenHash: "hashed-token",
|
||||
expiresAt: new Date("2026-03-30T12:30:00.000Z"),
|
||||
createdAt: new Date("2026-03-30T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-30T12:00:00.000Z"),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("upserts the active token for a user", async () => {
|
||||
vi.mocked(prisma.passwordResetToken.upsert).mockResolvedValue(mockTokenRecord as any);
|
||||
|
||||
const result = await upsertActiveToken(userId, "hashed-token", mockTokenRecord.expiresAt);
|
||||
|
||||
expect(result).toEqual(mockTokenRecord);
|
||||
expect(prisma.passwordResetToken.upsert).toHaveBeenCalledWith({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
tokenHash: "hashed-token",
|
||||
expiresAt: mockTokenRecord.expiresAt,
|
||||
},
|
||||
update: {
|
||||
tokenHash: "hashed-token",
|
||||
expiresAt: mockTokenRecord.expiresAt,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("finds a token by hash", async () => {
|
||||
vi.mocked(prisma.passwordResetToken.findUnique).mockResolvedValue(mockTokenRecord as any);
|
||||
|
||||
const result = await findByTokenHash("hashed-token");
|
||||
|
||||
expect(result).toEqual(mockTokenRecord);
|
||||
expect(prisma.passwordResetToken.findUnique).toHaveBeenCalledWith({
|
||||
where: { tokenHash: "hashed-token" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("deletes by token hash", async () => {
|
||||
vi.mocked(prisma.passwordResetToken.deleteMany).mockResolvedValue({ count: 1 } as any);
|
||||
|
||||
const result = await deleteByTokenHash("hashed-token");
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(prisma.passwordResetToken.deleteMany).toHaveBeenCalledWith({
|
||||
where: { tokenHash: "hashed-token" },
|
||||
});
|
||||
});
|
||||
|
||||
test("consumes only a non-expired token inside a transaction", async () => {
|
||||
const tx = {
|
||||
passwordResetToken: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
} as any;
|
||||
const now = new Date("2026-03-30T12:10:00.000Z");
|
||||
|
||||
const result = await consumeActiveToken("hashed-token", now, tx);
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(tx.passwordResetToken.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tokenHash: "hashed-token",
|
||||
expiresAt: {
|
||||
gt: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("wraps prisma known errors in DatabaseError", async () => {
|
||||
vi.mocked(prisma.passwordResetToken.upsert).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("database failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(upsertActiveToken(userId, "hashed-token", mockTokenRecord.expiresAt)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import "server-only";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
const passwordResetTokenSelection = {
|
||||
id: true,
|
||||
userId: true,
|
||||
tokenHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.PasswordResetTokenSelect;
|
||||
|
||||
const ZTokenHash = z.string().min(1);
|
||||
|
||||
type TPasswordResetTokenDbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
export type TPasswordResetTokenRecord = Prisma.PasswordResetTokenGetPayload<{
|
||||
select: typeof passwordResetTokenSelection;
|
||||
}>;
|
||||
|
||||
const getDbClient = (tx?: Prisma.TransactionClient): TPasswordResetTokenDbClient => tx ?? prisma;
|
||||
|
||||
const handleDatabaseError = (error: unknown): never => {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
|
||||
export const upsertActiveToken = async (
|
||||
userId: string,
|
||||
tokenHash: string,
|
||||
expiresAt: Date,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TPasswordResetTokenRecord> => {
|
||||
validateInputs([userId, ZId], [tokenHash, ZTokenHash], [expiresAt, z.date()]);
|
||||
|
||||
try {
|
||||
return await getDbClient(tx).passwordResetToken.upsert({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
},
|
||||
update: {
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
},
|
||||
select: passwordResetTokenSelection,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleDatabaseError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const findByTokenHash = async (
|
||||
tokenHash: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TPasswordResetTokenRecord | null> => {
|
||||
validateInputs([tokenHash, ZTokenHash]);
|
||||
|
||||
try {
|
||||
return await getDbClient(tx).passwordResetToken.findUnique({
|
||||
where: {
|
||||
tokenHash,
|
||||
},
|
||||
select: passwordResetTokenSelection,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleDatabaseError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteByTokenHash = async (
|
||||
tokenHash: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<number> => {
|
||||
validateInputs([tokenHash, ZTokenHash]);
|
||||
|
||||
try {
|
||||
const result = await getDbClient(tx).passwordResetToken.deleteMany({
|
||||
where: {
|
||||
tokenHash,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
return handleDatabaseError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const consumeActiveToken = async (
|
||||
tokenHash: string,
|
||||
now: Date,
|
||||
tx: Prisma.TransactionClient
|
||||
): Promise<number> => {
|
||||
validateInputs([tokenHash, ZTokenHash], [now, z.date()]);
|
||||
|
||||
try {
|
||||
const result = await tx.passwordResetToken.deleteMany({
|
||||
where: {
|
||||
tokenHash,
|
||||
expiresAt: {
|
||||
gt: now,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
return handleDatabaseError(error);
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user